Add library view and series drill-down (M8)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 19:31:12 +02:00
co-authored by Claude Sonnet 5.5
parent a469eca871
commit 61373bbe45
11 changed files with 1080 additions and 18 deletions
+55
View File
@@ -0,0 +1,55 @@
package arr
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
)
// Update reads <path>/<id>, lets mutate change the raw JSON object and PUTs it
// back. Working on raw fields keeps everything the client does not model
// (Radarr and Sonarr replace the whole resource on PUT).
func (c *Client) Update(ctx context.Context, path string, id int, mutate func(obj map[string]json.RawMessage) error) error {
p := path + "/" + strconv.Itoa(id)
var obj map[string]json.RawMessage
if err := c.Do(ctx, http.MethodGet, p, nil, nil, &obj); err != nil {
return err
}
if err := mutate(obj); err != nil {
return err
}
return c.Do(ctx, http.MethodPut, p, nil, obj, nil)
}
// SetBool sets a boolean field of a raw object.
func SetBool(obj map[string]json.RawMessage, key string, v bool) {
obj[key], _ = json.Marshal(v)
}
// Command posts a command ("MoviesSearch", "SeasonSearch", ...) to /command.
func (c *Client) Command(ctx context.Context, body map[string]any) error {
return c.Do(ctx, http.MethodPost, "command", nil, body, nil)
}
// SetSeasonMonitored flips one season in a raw series object.
func SetSeasonMonitored(obj map[string]json.RawMessage, season int, monitored bool) error {
var seasons []map[string]json.RawMessage
if err := json.Unmarshal(obj["seasons"], &seasons); err != nil {
return fmt.Errorf("decoding seasons: %w", err)
}
found := false
for _, s := range seasons {
var n int
if json.Unmarshal(s["seasonNumber"], &n) == nil && n == season {
SetBool(s, "monitored", monitored)
found = true
}
}
if !found {
return fmt.Errorf("season %d not found", season)
}
obj["seasons"], _ = json.Marshal(seasons)
return nil
}
+48
View File
@@ -0,0 +1,48 @@
package arr
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
)
func TestUpdateKeepsUnknownFields(t *testing.T) {
var put map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v3/movie/7" {
t.Errorf("path %s", r.URL.Path)
}
switch r.Method {
case http.MethodGet:
io.WriteString(w, `{"id":7,"monitored":true,"customThing":{"a":[1,2]},"seasons":[{"seasonNumber":1,"monitored":true,"x":1},{"seasonNumber":2,"monitored":true}]}`)
case http.MethodPut:
json.NewDecoder(r.Body).Decode(&put)
}
}))
defer srv.Close()
err := NewClient(srv.URL, "k").Update(context.Background(), "movie", 7, func(o map[string]json.RawMessage) error {
SetBool(o, "monitored", false)
return SetSeasonMonitored(o, 2, false)
})
if err != nil {
t.Fatal(err)
}
if put["monitored"] != false || put["customThing"] == nil {
t.Errorf("put = %v", put)
}
seasons := put["seasons"].([]any)
if seasons[0].(map[string]any)["monitored"] != true || seasons[1].(map[string]any)["monitored"] != false || seasons[0].(map[string]any)["x"] == nil {
t.Errorf("seasons = %v", seasons)
}
}
func TestSetSeasonMonitoredUnknownSeason(t *testing.T) {
obj := map[string]json.RawMessage{"seasons": json.RawMessage(`[{"seasonNumber":1,"monitored":true}]`)}
if err := SetSeasonMonitored(obj, 5, true); err == nil {
t.Error("want error")
}
}