From ff170773ac46822499067fa5a363206bf289c89f Mon Sep 17 00:00:00 2001 From: Storm Timmermans Date: Thu, 24 Nov 2022 23:46:01 +0100 Subject: [PATCH] feat(triggers): readarr (#174) Co-authored-by: voltron4lyfe <55123373+voltron4lyfe@users.noreply.github.com> Co-authored-by: l3uddz --- .github/workflows/build.yml | 65 ++++++------ README.md | 21 ++-- cmd/autoscan/main.go | 5 +- cmd/autoscan/router.go | 10 ++ triggers/readarr/readarr.go | 114 +++++++++++++++++++++ triggers/readarr/readarr_test.go | 121 +++++++++++++++++++++++ triggers/readarr/testdata/invalid.json | 1 + triggers/readarr/testdata/sanderson.json | 13 +++ triggers/readarr/testdata/test.json | 3 + 9 files changed, 313 insertions(+), 40 deletions(-) create mode 100644 triggers/readarr/readarr.go create mode 100644 triggers/readarr/readarr_test.go create mode 100644 triggers/readarr/testdata/invalid.json create mode 100644 triggers/readarr/testdata/sanderson.json create mode 100644 triggers/readarr/testdata/test.json diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 62ac4737..98d446f5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,6 +6,9 @@ on: - '*' tags: - 'v*' + pull_request: + branches: + - master jobs: build: @@ -13,7 +16,7 @@ jobs: steps: # dependencies - name: goreleaser - uses: goreleaser/goreleaser-action@v2 + uses: goreleaser/goreleaser-action@v3 with: install-only: true version: 1.7.0 @@ -28,20 +31,22 @@ jobs: run: task --version - name: qemu - uses: docker/setup-qemu-action@v1 + if: github.event.pull_request.head.repo.fork == false + uses: docker/setup-qemu-action@v2 - name: buildx - uses: docker/setup-buildx-action@v1 + if: github.event.pull_request.head.repo.fork == false + uses: docker/setup-buildx-action@v2 # checkout - name: checkout - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: fetch-depth: 0 # setup go - name: go - uses: actions/setup-go@v1 + uses: actions/setup-go@v3 with: go-version: 1.19 @@ -51,26 +56,18 @@ jobs: go env # cache - - name: cache-paths - id: go-cache-paths - run: | - echo "::set-output name=go-build::$(go env GOCACHE)" - echo "::set-output name=go-mod::$(go env GOMODCACHE)" - - - name: cache-build - uses: actions/cache@v2 - with: - path: ${{ steps.go-cache-paths.outputs.go-build }} - key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }} - - - name: cache-mod - uses: actions/cache@v2 + - name: cache-go + uses: actions/cache@v3 with: - path: ${{ steps.go-cache-paths.outputs.go-mod }} - key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }} + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go-cache-mod - name: cache-task - uses: actions/cache@v2 + uses: actions/cache@v3 with: path: .task/**/* key: ${{ runner.os }}-go-task @@ -106,19 +103,20 @@ jobs: # artifacts - name: artifact_linux - uses: actions/upload-artifact@v2-preview + uses: actions/upload-artifact@v3 with: name: build_linux path: dist/*linux* - name: artifact_darwin - uses: actions/upload-artifact@v2-preview + uses: actions/upload-artifact@v3 with: name: build_darwin path: dist/*darwin* # docker login - name: docker login + if: github.event.pull_request.head.repo.fork == false env: DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} @@ -127,15 +125,15 @@ jobs: # docker build (latest & tag) - name: release tag - if: startsWith(github.ref, 'refs/tags/') == true + if: startsWith(github.ref, 'refs/tags/') == true && github.event.pull_request.head.repo.fork == false uses: little-core-labs/get-git-tag@v3.0.2 id: releasetag with: tagRegex: "v?(.+)" - name: docker - build release - if: startsWith(github.ref, 'refs/tags/') == true - uses: docker/build-push-action@v2 + if: startsWith(github.ref, 'refs/tags/') == true && github.event.pull_request.head.repo.fork == false + uses: docker/build-push-action@v3 with: context: . file: ./docker/Dockerfile @@ -150,13 +148,13 @@ jobs: # docker build (branch) - name: branch name - if: startsWith(github.ref, 'refs/tags/') == false + if: startsWith(github.ref, 'refs/tags/') == false && github.event.pull_request.head.repo.fork == false id: branch-name - uses: tj-actions/branch-names@v2.2 + uses: tj-actions/branch-names@v6.2 - name: docker tag - if: startsWith(github.ref, 'refs/tags/') == false - uses: frabert/replace-string-action@master + if: startsWith(github.ref, 'refs/tags/') == false && github.event.pull_request.head.repo.fork == false + uses: frabert/replace-string-action@v2.3 id: dockertag with: pattern: '[:\.\/]+' @@ -165,8 +163,8 @@ jobs: flags: 'g' - name: docker - build branch - if: startsWith(github.ref, 'refs/tags/') == false - uses: docker/build-push-action@v2 + if: startsWith(github.ref, 'refs/tags/') == false && github.event.pull_request.head.repo.fork == false + uses: docker/build-push-action@v3 with: context: . file: ./docker/Dockerfile @@ -180,5 +178,6 @@ jobs: # cleanup - name: cleanup + if: github.event.pull_request.head.repo.fork == false run: | rm -f ${HOME}/.docker/config.json \ No newline at end of file diff --git a/README.md b/README.md index 35ee81e5..3bca9724 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Autoscan Autoscan replaces the default Plex and Emby behaviour for picking up file changes on the file system. -Autoscan integrates with Sonarr, Radarr, Lidarr and Google Drive to fetch changes in near real-time without relying on the file system. +Autoscan integrates with Sonarr, Radarr, Readarr, Lidarr and Google Drive to fetch changes in near real-time without relying on the file system. Wait, what happened to [Plex Autoscan](https://github.com/l3uddz/plex_autoscan)? Well, Autoscan is a rewrite of the original Plex Autoscan written in the Go language. @@ -61,7 +61,7 @@ It is important that all three modules can have access to a file. When a trigger #### Simple example -- Sonarr running in a Docker container (same example works for Lidarr and Radarr) +- Sonarr running in a Docker container (same example works for Lidarr, Radarr and Readarr) - Autoscan running on the host OS (not in a container) - Plex running in a Docker container @@ -117,8 +117,8 @@ Autoscan currently supports the following triggers: - Manual: When you want to scan a path manually. -- The -arrs: Lidarr, Sonarr and Radarr. \ - Webhook support for Lidarr, Sonarrr and Radarr. +- The -arrs: Lidarr, Sonarr, Radarr and Readarr. \ + Webhook support for Lidarr, Sonarr, Radarr and Readarr. All triggers support: @@ -180,13 +180,14 @@ The following -arrs are currently provided by Autoscan: - Lidarr - Radarr +- Readarr - Sonarr #### Connecting the -arrs -To add your webhook to Sonarr, Radarr or Lidarr, do: +To add your webhook to Sonarr, Radarr, Readarr or Lidarr, do: -1. Open the `settings` page in Sonarr/Radarr/Lidarr +1. Open the `settings` page in Sonarr/Radarr/Readarr/Lidarr 2. Select the tab `connect` 3. Click on the big plus sign 4. Select `webhook` @@ -267,6 +268,10 @@ triggers: - name: radarr4k # /triggers/radarr4k priority: 5 + readarr: + - name: readarr # /triggers/readarr + priority: 1 + sonarr: - name: sonarr-docker # /triggers/sonarr-docker priority: 2 @@ -478,6 +483,10 @@ triggers: - name: radarr4k # /triggers/radarr4k priority: 5 + readarr: + - name: readarr # /triggers/readarr + priority: 1 + sonarr: - name: sonarr-docker # /triggers/sonarr-docker priority: 2 diff --git a/cmd/autoscan/main.go b/cmd/autoscan/main.go index 0f2f05c3..eb5e235b 100644 --- a/cmd/autoscan/main.go +++ b/cmd/autoscan/main.go @@ -30,6 +30,7 @@ import ( "github.com/cloudbox/autoscan/triggers/lidarr" "github.com/cloudbox/autoscan/triggers/manual" "github.com/cloudbox/autoscan/triggers/radarr" + "github.com/cloudbox/autoscan/triggers/readarr" "github.com/cloudbox/autoscan/triggers/sonarr" // sqlite3 driver @@ -59,6 +60,7 @@ type config struct { Inotify []inotify.Config `yaml:"inotify"` Lidarr []lidarr.Config `yaml:"lidarr"` Radarr []radarr.Config `yaml:"radarr"` + Readarr []readarr.Config `yaml:"readarr"` Sonarr []sonarr.Config `yaml:"sonarr"` } `yaml:"triggers"` @@ -268,8 +270,9 @@ func main() { Int("bernard", len(c.Triggers.Bernard)). Int("inotify", len(c.Triggers.Inotify)). Int("lidarr", len(c.Triggers.Lidarr)). - Int("sonarr", len(c.Triggers.Sonarr)). Int("radarr", len(c.Triggers.Radarr)). + Int("readarr", len(c.Triggers.Readarr)). + Int("sonarr", len(c.Triggers.Sonarr)). Msg("Initialised triggers") // targets diff --git a/cmd/autoscan/router.go b/cmd/autoscan/router.go index 22ccc1ff..01452807 100644 --- a/cmd/autoscan/router.go +++ b/cmd/autoscan/router.go @@ -16,6 +16,7 @@ import ( "github.com/cloudbox/autoscan/triggers/lidarr" "github.com/cloudbox/autoscan/triggers/manual" "github.com/cloudbox/autoscan/triggers/radarr" + "github.com/cloudbox/autoscan/triggers/readarr" "github.com/cloudbox/autoscan/triggers/sonarr" ) @@ -96,6 +97,15 @@ func getRouter(c config, proc *processor.Processor) chi.Router { r.Post(pattern(t.Name), trigger(proc.Add).ServeHTTP) } + for _, t := range c.Triggers.Readarr { + trigger, err := readarr.New(t) + if err != nil { + log.Fatal().Err(err).Str("trigger", t.Name).Msg("Failed initialising trigger") + } + + r.Post(pattern(t.Name), trigger(proc.Add).ServeHTTP) + } + for _, t := range c.Triggers.Sonarr { trigger, err := sonarr.New(t) if err != nil { diff --git a/triggers/readarr/readarr.go b/triggers/readarr/readarr.go new file mode 100644 index 00000000..c89cc32b --- /dev/null +++ b/triggers/readarr/readarr.go @@ -0,0 +1,114 @@ +package readarr + +import ( + "encoding/json" + "net/http" + "path" + "strings" + "time" + + "github.com/rs/zerolog/hlog" + + "github.com/cloudbox/autoscan" +) + +type Config struct { + Name string `yaml:"name"` + Priority int `yaml:"priority"` + Rewrite []autoscan.Rewrite `yaml:"rewrite"` + Verbosity string `yaml:"verbosity"` +} + +// New creates an autoscan-compatible HTTP Trigger for Readarr webhooks. +func New(c Config) (autoscan.HTTPTrigger, error) { + rewriter, err := autoscan.NewRewriter(c.Rewrite) + if err != nil { + return nil, err + } + + trigger := func(callback autoscan.ProcessorFunc) http.Handler { + return handler{ + callback: callback, + priority: c.Priority, + rewrite: rewriter, + } + } + + return trigger, nil +} + +type handler struct { + priority int + rewrite autoscan.Rewriter + callback autoscan.ProcessorFunc +} + +type readarrEvent struct { + Type string `json:"eventType"` + Upgrade bool `json:"isUpgrade"` + + Files []struct { + Path string + } `json:"bookFiles"` +} + +func (h handler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { + var err error + l := hlog.FromRequest(r) + + event := new(readarrEvent) + err = json.NewDecoder(r.Body).Decode(event) + if err != nil { + l.Error().Err(err).Msg("Failed decoding request") + rw.WriteHeader(http.StatusBadRequest) + return + } + + l.Trace().Interface("event", event).Msg("Received JSON body") + + if strings.EqualFold(event.Type, "Test") { + l.Info().Msg("Received test event") + rw.WriteHeader(http.StatusOK) + return + } + + //Only handle test and download. Everything else is ignored. + if !strings.EqualFold(event.Type, "Download") || len(event.Files) == 0 { + l.Error().Msg("Required fields are missing") + rw.WriteHeader(http.StatusBadRequest) + return + } + + unique := make(map[string]bool) + scans := make([]autoscan.Scan, 0) + + for _, f := range event.Files { + folderPath := path.Dir(h.rewrite(f.Path)) + if _, ok := unique[folderPath]; ok { + continue + } + + // add scan + unique[folderPath] = true + scans = append(scans, autoscan.Scan{ + Folder: folderPath, + Priority: h.priority, + Time: now(), + }) + } + + err = h.callback(scans...) + if err != nil { + l.Error().Err(err).Msg("Processor could not process scans") + rw.WriteHeader(http.StatusInternalServerError) + return + } + + rw.WriteHeader(http.StatusOK) + l.Info(). + Str("path", scans[0].Folder). + Str("event", event.Type). + Msg("Scan moved to processor") +} + +var now = time.Now diff --git a/triggers/readarr/readarr_test.go b/triggers/readarr/readarr_test.go new file mode 100644 index 00000000..8c0b91ab --- /dev/null +++ b/triggers/readarr/readarr_test.go @@ -0,0 +1,121 @@ +package readarr + +import ( + "errors" + "net/http" + "net/http/httptest" + "os" + "reflect" + "testing" + "time" + + "github.com/cloudbox/autoscan" +) + +func TestHandler(t *testing.T) { + type Given struct { + Config Config + Fixture string + } + + type Expected struct { + Scans []autoscan.Scan + StatusCode int + } + + type Test struct { + Name string + Given Given + Expected Expected + } + + standardConfig := Config{ + Name: "readarr", + Priority: 5, + Rewrite: []autoscan.Rewrite{{ + From: "/Books/*", + To: "/mnt/unionfs/Media/Books/$1", + }}, + } + + currentTime := time.Now() + now = func() time.Time { + return currentTime + } + + var testCases = []Test{ + { + "Scan has all the correct fields", + Given{ + Config: standardConfig, + Fixture: "testdata/sanderson.json", + }, + Expected{ + StatusCode: 200, + Scans: []autoscan.Scan{{ + Folder: "/mnt/unionfs/Media/Books/Brandon Sanderson/The Way of Kings (2010)", + Priority: 5, + Time: currentTime, + }}, + }, + }, + { + "Returns bad request on invalid JSON", + Given{ + Config: standardConfig, + Fixture: "testdata/invalid.json", + }, + Expected{ + StatusCode: 400, + }, + }, + { + "Returns 200 on Test event without emitting a scan", + Given{ + Config: standardConfig, + Fixture: "testdata/test.json", + }, + Expected{ + StatusCode: 200, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + callback := func(scans ...autoscan.Scan) error { + if !reflect.DeepEqual(tc.Expected.Scans, scans) { + t.Logf("want: %v", tc.Expected.Scans) + t.Logf("got: %v", scans) + t.Errorf("Scans do not equal") + return errors.New("Scans do not equal") + } + + return nil + } + + trigger, err := New(tc.Given.Config) + if err != nil { + t.Fatalf("Could not create Readarr Trigger: %v", err) + } + + server := httptest.NewServer(trigger(callback)) + defer server.Close() + + request, err := os.Open(tc.Given.Fixture) + if err != nil { + t.Fatalf("Could not open the fixture: %s", tc.Given.Fixture) + } + + res, err := http.Post(server.URL, "application/json", request) + if err != nil { + t.Fatalf("Request failed: %v", err) + } + + defer res.Body.Close() + if res.StatusCode != tc.Expected.StatusCode { + t.Errorf("Status codes do not match: %d vs %d", res.StatusCode, tc.Expected.StatusCode) + } + }) + } +} diff --git a/triggers/readarr/testdata/invalid.json b/triggers/readarr/testdata/invalid.json new file mode 100644 index 00000000..d20c96e0 --- /dev/null +++ b/triggers/readarr/testdata/invalid.json @@ -0,0 +1 @@ +This is an invalid JSON file diff --git a/triggers/readarr/testdata/sanderson.json b/triggers/readarr/testdata/sanderson.json new file mode 100644 index 00000000..5e4051fd --- /dev/null +++ b/triggers/readarr/testdata/sanderson.json @@ -0,0 +1,13 @@ +{ + "eventType": "Download", + "isUpgrade": false, + "bookFiles": [ + { + "path": "/Books/Brandon Sanderson/The Way of Kings (2010)/The Way of Kings - Brandon Sanderson.epub" + } + ], + "author": { + "name": "Brandon Sanderson", + "path": "/Books/Brandon Sanderson" + } +} diff --git a/triggers/readarr/testdata/test.json b/triggers/readarr/testdata/test.json new file mode 100644 index 00000000..64ff3978 --- /dev/null +++ b/triggers/readarr/testdata/test.json @@ -0,0 +1,3 @@ +{ + "eventType": "Test" +}