Add interactive release search (M6)

Release listing and grab in the shared arr client, a full-screen release
list (sort, hide rejected, rejection reasons, confirmation before grabbing
rejected releases; Usenet only), and a season/episode picker for Sonarr.
Overlays are now a stack so dialogs can open above the release list.
Add a fake Newznab indexer to the dev setup.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 18:09:19 +02:00
co-authored by Claude Sonnet 5.5
parent 446cbdd6c5
commit f928427993
18 changed files with 1002 additions and 23 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ func NewClient(baseURL, apiKey string) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
apiKey: apiKey,
http: &http.Client{Timeout: 30 * time.Second},
http: &http.Client{Timeout: 2 * time.Minute}, // interactive release searches query every indexer
}
}
+66
View File
@@ -0,0 +1,66 @@
package arr
import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
)
// Release is an indexer search result from an interactive search.
type Release struct {
GUID string `json:"guid"`
Title string `json:"title"`
Indexer string `json:"indexer"`
IndexerID int `json:"indexerId"`
Protocol string `json:"protocol"` // usenet or torrent
Size int64 `json:"size"`
AgeMinutes float64 `json:"ageMinutes"`
QualityWeight int `json:"qualityWeight"`
CustomFormatScore int `json:"customFormatScore"`
ReleaseGroup string `json:"releaseGroup"`
Approved bool `json:"approved"`
Rejected bool `json:"rejected"`
TemporarilyRejected bool `json:"temporarilyRejected"`
Rejections []string `json:"rejections"`
FullSeason bool `json:"fullSeason"`
Quality struct {
Quality struct {
Name string `json:"name"`
} `json:"quality"`
} `json:"quality"`
Languages []struct {
Name string `json:"name"`
} `json:"languages"`
}
// QualityName returns the release's quality, e.g. "Bluray-1080p".
func (r Release) QualityName() string { return r.Quality.Quality.Name }
// LanguageNames returns the release's languages joined by commas.
func (r Release) LanguageNames() string {
names := make([]string, len(r.Languages))
for i, l := range r.Languages {
names[i] = l.Name
}
return strings.Join(names, ", ")
}
// Releases runs an interactive search with the given query, e.g. movieId=12.
// Searching queries every indexer and can take a while; use a generous timeout.
func (c *Client) Releases(ctx context.Context, query url.Values) ([]Release, error) {
var releases []Release
err := c.Do(ctx, http.MethodGet, "release", query, nil, &releases)
return releases, err
}
// Grab sends a release found by a previous search to the download client.
// Releases that were rejected can be grabbed as well.
func (c *Client) Grab(ctx context.Context, r Release) error {
if r.GUID == "" {
return fmt.Errorf("release has no guid")
}
body := map[string]any{"guid": r.GUID, "indexerId": r.IndexerID}
return c.Do(ctx, http.MethodPost, "release", nil, body, nil)
}
+66
View File
@@ -0,0 +1,66 @@
package arr
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"testing"
)
const releasesJSON = `[
{"guid":"g1","title":"Movie.2020.1080p.BluRay-GRP","indexer":"NZBgeek","indexerId":3,"protocol":"usenet","size":8589934592,
"ageMinutes":4320,"qualityWeight":1801,"customFormatScore":10,"releaseGroup":"GRP","approved":true,"rejected":false,
"rejections":[],"quality":{"quality":{"name":"Bluray-1080p"}},"languages":[{"name":"English"},{"name":"German"}]},
{"guid":"g2","title":"Movie.2020.720p-X","indexer":"Other","indexerId":4,"protocol":"torrent","approved":false,"rejected":true,
"rejections":["Quality Bluray-720p is not wanted in profile","Release is older than 30 days"],"quality":{"quality":{"name":"Bluray-720p"}}}
]`
func TestReleases(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v3/release" || r.URL.Query().Get("movieId") != "12" {
t.Errorf("unexpected request %s", r.URL)
}
w.Write([]byte(releasesJSON))
}))
defer srv.Close()
rels, err := NewClient(srv.URL, "k").Releases(context.Background(), url.Values{"movieId": {"12"}})
if err != nil {
t.Fatal(err)
}
if len(rels) != 2 {
t.Fatalf("got %d releases", len(rels))
}
a, b := rels[0], rels[1]
if a.QualityName() != "Bluray-1080p" || a.Size != 8<<30 || !a.Approved || a.IndexerID != 3 || a.LanguageNames() != "English, German" {
t.Errorf("release a = %+v", a)
}
if !b.Rejected || len(b.Rejections) != 2 || b.Protocol != "torrent" {
t.Errorf("release b = %+v", b)
}
}
func TestGrab(t *testing.T) {
var got map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/api/v3/release" {
t.Errorf("unexpected request %s %s", r.Method, r.URL)
}
json.NewDecoder(r.Body).Decode(&got)
w.Write([]byte(`{}`))
}))
defer srv.Close()
c := NewClient(srv.URL, "k")
if err := c.Grab(context.Background(), Release{GUID: "g1", IndexerID: 3}); err != nil {
t.Fatal(err)
}
if got["guid"] != "g1" || got["indexerId"] != float64(3) {
t.Errorf("body = %v", got)
}
if err := c.Grab(context.Background(), Release{}); err == nil {
t.Error("expected error for release without guid")
}
}