Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
937c922
feat: scaffold autoscan-arr scan_source plugin
fluxis Jun 3, 2026
af523f6
feat(arr): poll history for imports and renames
fluxis Jun 3, 2026
ccf86a2
feat(arr): boundary-safe path rewrites
fluxis Jun 3, 2026
dc62892
feat: PollChanges returns Silo-native paths from arr history (credent…
fluxis Jun 3, 2026
29e1822
fix(arr): never regress the returned marker on empty polls
fluxis Jun 3, 2026
9f584da
test: end-to-end PollChanges over go-plugin gRPC against stub arr
fluxis Jun 3, 2026
43667d0
docs: README (scan_source contract, credential-from-host, config)
fluxis Jun 3, 2026
b3a33e3
fix(arr): apply most-specific path rewrite regardless of order
fluxis Jun 3, 2026
62cec06
fix(arr): clamp a future-dated marker to now
fluxis Jun 3, 2026
60cf349
refactor(arr): return raw source paths; host now owns path rewrites
fluxis Jun 3, 2026
07c78f5
build: depend on merged silo-plugin-sdk via pseudo-version (drop loca…
fluxis Jun 3, 2026
0551dd4
fix(arr): paginate history to avoid 1MiB truncation stall
fluxis Jun 3, 2026
974a786
fix(arr): error on an unparseable marker instead of resetting to now
fluxis Jun 3, 2026
44f21df
fix(deps): bump grpc to v1.81.1 (GHSA-p77j-4mvh-x3m3)
fluxis Jun 3, 2026
95698ef
fix(arr): composite (date,id) marker to avoid dropping same-second hi…
fluxis Jun 3, 2026
9543404
docs: fix README endpoint, build targets, and SDK dependency note
fluxis Jun 3, 2026
463ba3f
docs: add doc comments across the plugin (coverage)
fluxis Jun 3, 2026
9c8835c
fix(arr): preserve composite marker on idle polls; honest page-cap wa…
fluxis Jun 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/plugin
/dist/
25 changes: 25 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
.PHONY: build test lint clean build-all

BINARY=plugin
PLATFORMS=linux/amd64 linux/arm64 darwin/arm64
VERSION ?= $(shell git describe --tags --always 2>/dev/null | sed 's/^v//')
LDFLAGS=-s -w -X main.version=$(VERSION)

build:
go build -trimpath -ldflags="$(LDFLAGS)" -o $(BINARY) .

test:
go test ./...

lint:
golangci-lint run ./...

clean:
rm -f $(BINARY)

build-all:
@mkdir -p dist
@for platform in $(PLATFORMS); do \
GOOS=$${platform%%/*} GOARCH=$${platform##*/} CGO_ENABLED=0 \
go build -trimpath -ldflags="$(LDFLAGS)" -o dist/$(BINARY)-$${platform%%/*}-$${platform##*/} .; \
done
Comment thread
coderabbitai[bot] marked this conversation as resolved.
52 changes: 43 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,52 @@
# Silo Autoscan ARR Plugin
# silo-plugin-autoscan-arr

First-party Silo autoscan plugin for ARR integrations.
A Silo plugin that implements the **`scan_source.v1`** capability for **Sonarr / Radarr**.
When Silo's host polls it, the plugin reads the arr instance's recent history,
extracts imported and renamed file paths, and hands the **raw arr-side paths** back to
the host. The host applies any configured path rewrites and triggers targeted library
rescans.

## Status
## How it works

Silo's host owns the polling timer and calls `PollChanges(marker, connection)` on
each tick. The plugin:

1. Reads the resolved arr `{base_url, api_key}` from `PollChangesRequest.connection`
(the host resolves the operator's connection — own credentials or a reused
Requests link — and passes them per call). **The plugin stores no credentials.**
2. Queries arr `/api/v3/history` (paginated: `page` / `pageSize` /
`sortKey=date` / `sortDirection=descending`), bounded to a 24h max-lookback
with a 1-minute overlap so boundary events aren't missed and a long-idle
source doesn't replay full history.
3. Extracts `downloadFolderImported` (new files) and `episodeFileRenamed` /
`movieFileRenamed` (both the new and old path). Deletes are ignored — upgrade
deletes are covered by the paired import.
4. Returns the **raw arr-side paths** in `PollChangesResponse.source_paths` plus an
opaque `next_marker` (a composite `<RFC3339>|<id>` cursor that never regresses
below the caller's marker; a bare RFC3339 string is still accepted for backward
compatibility). **Path rewrites are applied by the host**, not the plugin.

This repository is an early scaffold. Implementation details, plugin capabilities, and host integration contracts are still being defined.
## Configuration

## Development
The plugin has **no configuration**. `global_config_schema` is empty (`[]`).

The arr connection (URL + API key) is supplied by the host on every poll via
`PollChangesRequest.connection` — it is never stored by the plugin. Path rewrites are
configured in the host and applied after the plugin returns source paths.

## Building

```sh
go test ./...
go build .
make build # build the binary for the current platform
make build-all # cross-platform binaries (dist/)
go test ./... # unit tests
go test -tags integration ./... # spawns the real binary over go-plugin gRPC
```

## License
## Status

License details will be added before the first release.
Depends on `silo-plugin-sdk` ≥ the release that adds `scan_source.v1` with the
`connection` field on `PollChangesRequest` and `source_paths` on `PollChangesResponse`.
`go.mod` depends on the SDK via a pseudo-version (no local `replace`); bump it to a
tagged release once one is published. Catalog registration in `silo-plugins` is a
separate step.
115 changes: 115 additions & 0 deletions e2e_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
//go:build integration

// End-to-end test: builds the real plugin binary, spawns it over go-plugin
// (the same handshake silo's host uses), and drives PollChanges across the
// gRPC boundary against a stub arr — exercising the scan_source.v1 contract
// including the resolved-connection delivery. Run: go test -tags integration .
package main

import (
"context"
"net/http"
"net/http/httptest"
"os/exec"
"path/filepath"
"testing"
"time"

plugin "github.com/hashicorp/go-plugin"

pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
"github.com/Silo-Server/silo-plugin-sdk/pkg/pluginsdk/runtime"
)

func TestEndToEndPollChangesOverGRPC(t *testing.T) {
const apiKey = "secret-key"
const importedPath = "/data/media/tv/Show/Season 01/Show - S01E01.mkv"

gotAPIKey := make(chan string, 1)
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case gotAPIKey <- r.Header.Get("X-Api-Key"):
default:
}
// Compute a fresh "just now" date on every request so the record always falls
// within the paged query window (querySince = now - overlap) regardless of how
// long the binary build took before the first poll arrives.
freshDate := time.Now().UTC().Add(-10 * time.Second).Format(time.RFC3339)
// Return the paged envelope that the plugin now expects from /api/v3/history.
_, _ = w.Write([]byte(`{"page":1,"pageSize":200,"totalRecords":2,"records":[
{"eventType":"downloadFolderImported","date":"` + freshDate + `","data":{"importedPath":"` + importedPath + `"}},
{"eventType":"grabbed","date":"` + freshDate + `","data":{"importedPath":"/ignored"}}
]}`))
}))
defer stub.Close()

// Build the real plugin binary (uses this module's go.mod + SDK replace).
bin := filepath.Join(t.TempDir(), "autoscan-arr")
if out, err := exec.Command("go", "build", "-o", bin, ".").CombinedOutput(); err != nil {
t.Fatalf("build plugin: %v\n%s", err, out)
}

// Spawn it exactly as the host does.
client := plugin.NewClient(&plugin.ClientConfig{
HandshakeConfig: runtime.HandshakeConfig(),
Plugins: runtime.DefaultPluginSet(runtime.CapabilityServers{}),
Cmd: exec.Command(bin),
AllowedProtocols: []plugin.Protocol{plugin.ProtocolGRPC},
})
defer client.Kill()

rpc, err := client.Client()
if err != nil {
t.Fatalf("connect to plugin: %v", err)
}
raw, err := rpc.Dispense(runtime.PluginSetName)
if err != nil {
t.Fatalf("dispense: %v", err)
}
rt, ok := raw.(*runtime.Client)
if !ok {
t.Fatalf("dispensed %T, want *runtime.Client", raw)
}

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

// First poll: empty marker => "from now"; connection carries the creds.
resp, err := rt.ScanSource().PollChanges(ctx, &pluginv1.PollChangesRequest{
CapabilityId: "arr",
Connection: &pluginv1.ResolvedConnection{BaseUrl: stub.URL, ApiKey: apiKey},
})
if err != nil {
t.Fatalf("PollChanges: %v", err)
}

// The plugin must have reached the stub using the api key from the request.
select {
case k := <-gotAPIKey:
if k != apiKey {
t.Fatalf("arr received X-Api-Key %q, want %q", k, apiKey)
}
case <-time.After(5 * time.Second):
t.Fatal("plugin never called the stub arr")
}

// The raw arr-side imported path must come back (grabbed ignored); no
// rewriting applied — the host owns that. Marker non-empty.
var found bool
for _, p := range resp.GetSourcePaths() {
if p == importedPath {
found = true
}
}
if !found {
t.Fatalf("imported path missing from source_paths %v", resp.GetSourcePaths())
}
if resp.GetNextMarker() == "" {
t.Fatal("expected a non-empty next_marker")
}

// Nil connection must be rejected (no creds to poll with).
if _, err := rt.ScanSource().PollChanges(ctx, &pluginv1.PollChangesRequest{CapabilityId: "arr"}); err == nil {
t.Fatal("PollChanges with no connection should error")
}
}
24 changes: 24 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
module github.com/Silo-Server/silo-plugin-autoscan-arr

go 1.26.3

require (
github.com/Silo-Server/silo-plugin-sdk v0.4.1-0.20260603030807-807b07e785b2
github.com/hashicorp/go-plugin v1.8.0
)

require (
github.com/fatih/color v1.13.0 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/hashicorp/go-hclog v1.6.3 // indirect
github.com/hashicorp/yamux v0.1.2 // indirect
github.com/mattn/go-colorable v0.1.12 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect
github.com/oklog/run v1.1.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
google.golang.org/grpc v1.81.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
78 changes: 78 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
github.com/Silo-Server/silo-plugin-sdk v0.4.1-0.20260603030807-807b07e785b2 h1:t7pdSay39b6doyYhT+GiMWUMW1sTtj5Xw8k5fBPxiCA=
github.com/Silo-Server/silo-plugin-sdk v0.4.1-0.20260603030807-807b07e785b2/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw=
github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-plugin v1.8.0 h1:ie8S6RRY8RvB2usYZv+AAZ/wBvx2AU5p5QeP5j/FORs=
github.com/hashicorp/go-plugin v1.8.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8=
github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8=
github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94=
github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8=
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA=
github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s=
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
76 changes: 76 additions & 0 deletions internal/arr/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// Package arr polls a Sonarr/Radarr instance's history for recently-changed
// file paths. Paths are returned as-is (raw arr-side); the host applies rewrites.
package arr

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)

// maxResponseBody caps the arr history response read to 1 MiB.
const maxResponseBody = 1 << 20

// defaultTimeout bounds a single arr HTTP request.
const defaultTimeout = 30 * time.Second

// client is a minimal arr HTTP client. It authenticates with the X-Api-Key
// header and caps the response body. Credentials are passed per call (the
// plugin stores none).
type client struct {
baseURL string
apiKey string
httpClient *http.Client
}

// newClient builds an arr HTTP client. The base URL is trimmed of trailing
// slashes and surrounding whitespace, the api key is trimmed, and a default
// 30s-timeout http.Client is used when httpClient is nil (tests inject their
// own).
func newClient(baseURL, apiKey string, httpClient *http.Client) *client {
if httpClient == nil {
httpClient = &http.Client{Timeout: defaultTimeout}
}
return &client{
baseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"),
apiKey: strings.TrimSpace(apiKey),
httpClient: httpClient,
}
}

// getJSON issues a GET against the arr API and decodes the JSON response into
// dest. The response body is limited to maxResponseBody bytes.
func (c *client) getJSON(ctx context.Context, path string, dest any) error {
if c.baseURL == "" {
return fmt.Errorf("arr: base url is required")
}
if c.apiKey == "" {
return fmt.Errorf("arr: api key is required")
}

req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
if err != nil {
return fmt.Errorf("arr: create request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("X-Api-Key", c.apiKey)

resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("arr: request failed: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody))
return fmt.Errorf("arr: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
if err := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBody)).Decode(dest); err != nil {
return fmt.Errorf("arr: decode response: %w", err)
}
return nil
}
Loading