feat: add radarr support (#9)
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is failing Details

Reviewed-on: #9
This commit is contained in:
Dawid Wysokiński 2022-07-20 05:14:22 +00:00
parent ef0e97a245
commit 0313fd7aa3
12 changed files with 499 additions and 22 deletions

View File

@ -18,6 +18,8 @@ func TestErrorCode_String(t *testing.T) {
}
func TestError(t *testing.T) {
t.Parallel()
errToWrap := errors.New("WithErr")
tests := []struct {
name string

View File

@ -0,0 +1,38 @@
package domain
type RadarrEventType string
const (
RadarrEventTypeDownload RadarrEventType = "Download"
RadarrEventTypeTest RadarrEventType = "Test"
)
var (
ErrUnsupportedRadarrEventType = NewError(WithCode(ErrorCodeValidation), WithMessage("unsupported event type"))
)
func NewRadarrEventType(s string) (RadarrEventType, error) {
conv := RadarrEventType(s)
switch conv {
case RadarrEventTypeDownload,
RadarrEventTypeTest:
return conv, nil
default:
return "", ErrUnsupportedSonarrEventType
}
}
func (r RadarrEventType) String() string {
return string(r)
}
type RadarrMovie struct {
ID int64
Title string
ReleaseDate string
}
type RadarrWebhookPayload struct {
EventType RadarrEventType
Movie RadarrMovie
}

View File

@ -0,0 +1,36 @@
package domain_test
import (
"testing"
"gitea.dwysokinski.me/Kichiyaki/notificationarr/internal/domain"
"github.com/stretchr/testify/assert"
)
func TestNewRadarrEventType(t *testing.T) {
t.Parallel()
t.Run("OK", func(t *testing.T) {
t.Parallel()
events := []domain.RadarrEventType{domain.RadarrEventTypeDownload, domain.RadarrEventTypeTest}
for _, ev := range events {
res, err := domain.NewRadarrEventType(ev.String())
assert.Equal(t, ev, res)
assert.NoError(t, err)
}
})
t.Run("ERR: invalid event type", func(t *testing.T) {
t.Parallel()
events := []string{"test1", "test2", "aaaa", "bbb"}
for _, ev := range events {
res, err := domain.NewRadarrEventType(ev)
assert.Zero(t, res)
assert.ErrorIs(t, err, domain.ErrUnsupportedRadarrEventType)
}
})
}

View File

@ -12,7 +12,9 @@ func TestNewSonarrEventType(t *testing.T) {
t.Parallel()
t.Run("OK", func(t *testing.T) {
events := []domain.SonarrEventType{domain.SonarrEventTypeDownload}
t.Parallel()
events := []domain.SonarrEventType{domain.SonarrEventTypeDownload, domain.SonarrEventTypeTest}
for _, ev := range events {
res, err := domain.NewSonarrEventType(ev.String())
@ -22,6 +24,8 @@ func TestNewSonarrEventType(t *testing.T) {
})
t.Run("ERR: invalid event type", func(t *testing.T) {
t.Parallel()
events := []string{"test1", "test2", "aaaa", "bbb"}
for _, ev := range events {

View File

@ -0,0 +1,115 @@
// Code generated by counterfeiter. DO NOT EDIT.
package mock
import (
"context"
"sync"
"gitea.dwysokinski.me/Kichiyaki/notificationarr/internal/domain"
"gitea.dwysokinski.me/Kichiyaki/notificationarr/internal/rest"
)
type FakeRadarrService struct {
ProcessStub func(context.Context, domain.RadarrWebhookPayload) error
processMutex sync.RWMutex
processArgsForCall []struct {
arg1 context.Context
arg2 domain.RadarrWebhookPayload
}
processReturns struct {
result1 error
}
processReturnsOnCall map[int]struct {
result1 error
}
invocations map[string][][]interface{}
invocationsMutex sync.RWMutex
}
func (fake *FakeRadarrService) Process(arg1 context.Context, arg2 domain.RadarrWebhookPayload) error {
fake.processMutex.Lock()
ret, specificReturn := fake.processReturnsOnCall[len(fake.processArgsForCall)]
fake.processArgsForCall = append(fake.processArgsForCall, struct {
arg1 context.Context
arg2 domain.RadarrWebhookPayload
}{arg1, arg2})
stub := fake.ProcessStub
fakeReturns := fake.processReturns
fake.recordInvocation("Process", []interface{}{arg1, arg2})
fake.processMutex.Unlock()
if stub != nil {
return stub(arg1, arg2)
}
if specificReturn {
return ret.result1
}
return fakeReturns.result1
}
func (fake *FakeRadarrService) ProcessCallCount() int {
fake.processMutex.RLock()
defer fake.processMutex.RUnlock()
return len(fake.processArgsForCall)
}
func (fake *FakeRadarrService) ProcessCalls(stub func(context.Context, domain.RadarrWebhookPayload) error) {
fake.processMutex.Lock()
defer fake.processMutex.Unlock()
fake.ProcessStub = stub
}
func (fake *FakeRadarrService) ProcessArgsForCall(i int) (context.Context, domain.RadarrWebhookPayload) {
fake.processMutex.RLock()
defer fake.processMutex.RUnlock()
argsForCall := fake.processArgsForCall[i]
return argsForCall.arg1, argsForCall.arg2
}
func (fake *FakeRadarrService) ProcessReturns(result1 error) {
fake.processMutex.Lock()
defer fake.processMutex.Unlock()
fake.ProcessStub = nil
fake.processReturns = struct {
result1 error
}{result1}
}
func (fake *FakeRadarrService) ProcessReturnsOnCall(i int, result1 error) {
fake.processMutex.Lock()
defer fake.processMutex.Unlock()
fake.ProcessStub = nil
if fake.processReturnsOnCall == nil {
fake.processReturnsOnCall = make(map[int]struct {
result1 error
})
}
fake.processReturnsOnCall[i] = struct {
result1 error
}{result1}
}
func (fake *FakeRadarrService) Invocations() map[string][][]interface{} {
fake.invocationsMutex.RLock()
defer fake.invocationsMutex.RUnlock()
fake.processMutex.RLock()
defer fake.processMutex.RUnlock()
copiedInvocations := map[string][][]interface{}{}
for key, value := range fake.invocations {
copiedInvocations[key] = value
}
return copiedInvocations
}
func (fake *FakeRadarrService) recordInvocation(key string, args []interface{}) {
fake.invocationsMutex.Lock()
defer fake.invocationsMutex.Unlock()
if fake.invocations == nil {
fake.invocations = map[string][][]interface{}{}
}
if fake.invocations[key] == nil {
fake.invocations[key] = [][]interface{}{}
}
fake.invocations[key] = append(fake.invocations[key], args)
}
var _ rest.RadarrService = new(FakeRadarrService)

View File

@ -16,18 +16,26 @@ type SonarrService interface {
Process(ctx context.Context, payload domain.SonarrWebhookPayload) error
}
type WebhookHandler struct {
sonarrSvc SonarrService
//counterfeiter:generate -o internal/mock/radarr_service.gen.go . RadarrService
type RadarrService interface {
Process(ctx context.Context, payload domain.RadarrWebhookPayload) error
}
func NewWebhookHandler(sonarrSvc SonarrService) *WebhookHandler {
type WebhookHandler struct {
sonarrSvc SonarrService
radarrSvc RadarrService
}
func NewWebhookHandler(sonarrSvc SonarrService, radarrSvc RadarrService) *WebhookHandler {
return &WebhookHandler{
sonarrSvc: sonarrSvc,
radarrSvc: radarrSvc,
}
}
func (h *WebhookHandler) Register(r chi.Router) {
r.Post("/webhook/sonarr", h.handleSonarrWebhook)
r.Post("/webhook/radarr", h.handleRadarrWebhook)
}
type SonarrSeries struct {
@ -90,3 +98,48 @@ func (h *WebhookHandler) handleSonarrWebhook(w http.ResponseWriter, r *http.Requ
w.WriteHeader(http.StatusNoContent)
}
type RadarrMovie struct {
ID int64 `json:"id"`
Title string `json:"title"`
ReleaseDate string `json:"releaseDate"`
}
type RadarrWebhookRequest struct {
EventType string `json:"eventType"`
Movie RadarrMovie `json:"movie"`
}
func (h *WebhookHandler) handleRadarrWebhook(w http.ResponseWriter, r *http.Request) {
var req RadarrWebhookRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
renderErrorResponse(w, domain.NewError(
domain.WithErr(err),
domain.WithCode(domain.ErrorCodeValidation),
domain.WithMessage("invalid request body"),
))
return
}
eventType, err := domain.NewRadarrEventType(req.EventType)
if err != nil {
renderErrorResponse(w, fmt.Errorf("domain.NewRadarrEventType: %w", err))
return
}
payload := domain.RadarrWebhookPayload{
EventType: eventType,
Movie: domain.RadarrMovie{
ID: req.Movie.ID,
Title: req.Movie.Title,
ReleaseDate: req.Movie.ReleaseDate,
},
}
if err := h.radarrSvc.Process(r.Context(), payload); err != nil {
renderErrorResponse(w, fmt.Errorf("RadarrService.Process: %w", err))
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@ -117,7 +117,7 @@ func TestWebhookHandler_Sonarr(t *testing.T) {
tt.setup(&svc)
}
router := chi.NewRouter()
rest.NewWebhookHandler(&svc).Register(router)
rest.NewWebhookHandler(&svc, nil).Register(router)
resp := doRequest(router, http.MethodPost, "/webhook/sonarr", tt.body)
defer assert.NoError(t, resp.Body.Close())
@ -129,3 +129,102 @@ func TestWebhookHandler_Sonarr(t *testing.T) {
})
}
}
func TestWebhookHandler_Radarr(t *testing.T) {
t.Parallel()
tests := []struct {
name string
setup func(svc *mock.FakeRadarrService)
body io.Reader
expectedStatus int
target any
expectedResponse any
}{
{
name: "OK: event type=Download",
setup: func(svc *mock.FakeRadarrService) {
svc.ProcessReturns(nil)
},
body: func() *bytes.Buffer {
var buf bytes.Buffer
_ = json.NewEncoder(&buf).Encode(rest.RadarrWebhookRequest{
EventType: "Download",
Movie: rest.RadarrMovie{
ID: 111,
Title: "Title 1",
ReleaseDate: "1970-01-01",
},
})
return &buf
}(),
expectedStatus: http.StatusNoContent,
},
{
name: "ERR: only JSON is accepted",
setup: func(svc *mock.FakeRadarrService) {},
body: func() *bytes.Buffer {
var buf bytes.Buffer
_ = gob.NewEncoder(&buf).Encode(rest.RadarrWebhookRequest{
EventType: "Download",
Movie: rest.RadarrMovie{
ID: 111,
Title: "Title 1",
ReleaseDate: "1970-01-01",
},
})
return &buf
}(),
expectedStatus: http.StatusBadRequest,
target: &rest.ErrorResponse{},
expectedResponse: &rest.ErrorResponse{
Error: rest.APIError{
Code: domain.ErrorCodeValidation.String(),
Message: "invalid request body",
},
},
},
{
name: "ERR: unsupported event type",
setup: func(svc *mock.FakeRadarrService) {},
body: func() *bytes.Buffer {
var buf bytes.Buffer
_ = json.NewEncoder(&buf).Encode(rest.RadarrWebhookRequest{
EventType: "xxxx",
})
return &buf
}(),
expectedStatus: http.StatusBadRequest,
target: &rest.ErrorResponse{},
expectedResponse: &rest.ErrorResponse{
Error: rest.APIError{
Code: domain.ErrUnsupportedRadarrEventType.Code().String(),
Message: domain.ErrUnsupportedRadarrEventType.Message(),
},
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
svc := mock.FakeRadarrService{}
if tt.setup != nil {
tt.setup(&svc)
}
router := chi.NewRouter()
rest.NewWebhookHandler(nil, &svc).Register(router)
resp := doRequest(router, http.MethodPost, "/webhook/radarr", tt.body)
defer assert.NoError(t, resp.Body.Close())
if tt.target != nil && tt.expectedResponse != nil {
assertJSONResponse(t, resp, tt.expectedStatus, tt.expectedResponse, tt.target)
} else {
assert.Equal(t, tt.expectedStatus, resp.StatusCode)
}
})
}
}

View File

@ -0,0 +1,56 @@
package service
import (
"context"
"fmt"
"gitea.dwysokinski.me/Kichiyaki/notificationarr/internal/domain"
)
type Radarr struct {
publisher Publisher
}
func NewRadarr(publisher Publisher) *Radarr {
return &Radarr{
publisher: publisher,
}
}
func (s *Radarr) Process(ctx context.Context, payload domain.RadarrWebhookPayload) error {
title, err := s.buildTitle(payload.EventType, payload.Movie)
if err != nil {
return fmt.Errorf("buildTitle: %w", err)
}
msg, err := s.buildMessage(payload.EventType, payload.Movie)
if err != nil {
return fmt.Errorf("buildMessage: %w", err)
}
if err := s.publisher.Publish(ctx, title, msg); err != nil {
return fmt.Errorf("Publisher.Publish: %w", err)
}
return nil
}
func (s *Radarr) buildTitle(evType domain.RadarrEventType, _ domain.RadarrMovie) (string, error) {
switch evType {
case domain.RadarrEventTypeDownload,
domain.RadarrEventTypeTest:
return "New movie available (Radarr)", nil
default:
return "", domain.ErrUnsupportedRadarrEventType
}
}
func (s *Radarr) buildMessage(evType domain.RadarrEventType, movie domain.RadarrMovie) (string, error) {
switch evType {
case domain.RadarrEventTypeDownload,
domain.RadarrEventTypeTest:
return movie.Title, nil
default:
return "", domain.ErrUnsupportedRadarrEventType
}
}

View File

@ -0,0 +1,56 @@
package service_test
import (
"context"
"math/rand"
"testing"
"time"
"gitea.dwysokinski.me/Kichiyaki/notificationarr/internal/domain"
"gitea.dwysokinski.me/Kichiyaki/notificationarr/internal/service"
"gitea.dwysokinski.me/Kichiyaki/notificationarr/internal/service/internal/mock"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
)
func TestRadarr_Process(t *testing.T) {
t.Parallel()
t.Run("OK", func(t *testing.T) {
t.Parallel()
for _, evType := range [...]domain.RadarrEventType{
domain.RadarrEventTypeDownload,
domain.RadarrEventTypeTest,
} {
t.Run("event type="+evType.String(), func(t *testing.T) {
t.Parallel()
publisher := &mock.FakePublisher{}
publisher.PublishReturns(nil)
payload := generateRadarrWebhookPayload(evType)
err := service.NewRadarr(publisher).Process(context.Background(), payload)
assert.NoError(t, err)
_, title, msg := publisher.PublishArgsForCall(0)
assert.Equal(t, "New movie available (Radarr)", title)
assert.Equal(t, payload.Movie.Title, msg)
})
}
})
}
func generateRadarrWebhookPayload(ev domain.RadarrEventType) domain.RadarrWebhookPayload {
s := rand.NewSource(time.Now().UnixNano())
r := rand.New(s)
return domain.RadarrWebhookPayload{
EventType: ev,
Movie: domain.RadarrMovie{
ID: r.Int63(),
Title: uuid.NewString(),
ReleaseDate: "1970-01-01",
},
}
}

View File

@ -41,7 +41,7 @@ func (s *Sonarr) buildTitle(evType domain.SonarrEventType, series domain.SonarrS
switch evType {
case domain.SonarrEventTypeDownload,
domain.SonarrEventTypeTest:
return series.Title + " - New episode (Sonarr)", nil
return series.Title + " - New episode available (Sonarr)", nil
default:
return "", domain.ErrUnsupportedSonarrEventType
}

View File

@ -41,7 +41,7 @@ func TestSonarr_Process(t *testing.T) {
require.Equal(t, len(payload.Episodes), publisher.PublishCallCount())
for i, ep := range payload.Episodes {
_, title, msg := publisher.PublishArgsForCall(i)
assert.Equal(t, payload.Series.Title+" - New episode (Sonarr)", title)
assert.Equal(t, payload.Series.Title+" - New episode available (Sonarr)", title)
assert.Equal(t, fmt.Sprintf("S%d.E%d %s", ep.SeasonNumber, ep.EpisodeNumber, ep.Title), msg)
}
})

48
main.go
View File

@ -2,7 +2,6 @@ package main
import (
"context"
"errors"
"fmt"
"log"
"net/http"
@ -71,6 +70,11 @@ func newRouter() (*chi.Mux, error) {
return nil, fmt.Errorf("newSonarrService: %w", err)
}
radarrSvc, err := newRadarrService()
if err != nil {
return nil, fmt.Errorf("newRadarrService: %w", err)
}
r := chi.NewRouter()
r.Use(
middleware.RealIP,
@ -81,39 +85,53 @@ func newRouter() (*chi.Mux, error) {
middleware.Recoverer,
middleware.Heartbeat("/health"),
)
rest.NewWebhookHandler(sonarrSvc).Register(r)
rest.NewWebhookHandler(sonarrSvc, radarrSvc).Register(r)
return r, nil
}
func newRadarrService() (*service.Radarr, error) {
ntfy, err := newNtfyService("RADARR")
if err != nil {
return nil, fmt.Errorf("newNtfyService: %w", err)
}
return service.NewRadarr(ntfy), nil
}
func newSonarrService() (*service.Sonarr, error) {
url := os.Getenv("SONARR_NTFY_URL")
ntfy, err := newNtfyService("SONARR")
if err != nil {
return nil, fmt.Errorf("newNtfyService: %w", err)
}
return service.NewSonarr(ntfy), nil
}
func newNtfyService(envPrefix string) (*service.Ntfy, error) {
url := os.Getenv(envPrefix + "_NTFY_URL")
if url == "" {
url = ntfyDefaultUrl
}
topic := os.Getenv("SONARR_NTFY_TOPIC")
topic := os.Getenv(envPrefix + "_NTFY_TOPIC")
if topic == "" {
return nil, errors.New(`env "SONARR_NTFY_TOPIC" is required`)
return nil, fmt.Errorf(`env "%s_NTFY_TOPIC" is required`, envPrefix)
}
timeout := os.Getenv("SONARR_NTFY_REQ_TIMEOUT")
timeout := os.Getenv(envPrefix + "_NTFY_REQ_TIMEOUT")
parsedTimeout := publisherDefaultTimeout
var err error
if timeout != "" {
parsedTimeout, err = time.ParseDuration(timeout)
}
if err != nil {
return nil, fmt.Errorf(`os.Getenv("SONARR_NTFY_REQ_TIMEOUT"): %w`, err)
return nil, fmt.Errorf(`os.Getenv("%s_NTFY_REQ_TIMEOUT"): %w`, envPrefix, err)
}
return service.NewSonarr(
service.NewNtfy(
&http.Client{Timeout: parsedTimeout},
url,
topic,
os.Getenv("SONARR_NTFY_USERNAME"),
os.Getenv("SONARR_NTFY_PASSWORD"),
),
return service.NewNtfy(
&http.Client{Timeout: parsedTimeout},
url,
topic,
os.Getenv(envPrefix+"_NTFY_USERNAME"),
os.Getenv(envPrefix+"_NTFY_PASSWORD"),
), nil
}