Add Sonarr client and series search/add flow (M3)

Sonarr client with lookup, add series (monitor modes) and search.
Refactor the movie view into a generic search view driven by a backend
interface so Movies and Series share one implementation.
Add F1-F4 / Alt+1-4 view switching that works while typing.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 17:49:11 +02:00
co-authored by Claude Sonnet 5.5
parent 51fb0556b0
commit 2dd7c17737
8 changed files with 631 additions and 68 deletions
+117
View File
@@ -0,0 +1,117 @@
package sonarr
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
const lookupJSON = `[
{"id":0,"title":"Breaking Bad","year":2008,"tvdbId":81189,"titleSlug":"breaking-bad","network":"AMC",
"status":"ended","runtime":47,"genres":["Crime","Drama"],"seriesType":"standard",
"ratings":{"votes":100,"value":9.3},
"seasons":[{"seasonNumber":0,"monitored":false},{"seasonNumber":1,"monitored":false},{"seasonNumber":2,"monitored":false}],
"images":[{"coverType":"poster","remoteUrl":"https://img/p.jpg"}]},
{"id":12,"title":"Better Call Saul","year":2015,"tvdbId":273181}
]`
func newTestClient(t *testing.T, h http.HandlerFunc) *Client {
t.Helper()
srv := httptest.NewServer(h)
t.Cleanup(srv.Close)
return NewClient(srv.URL, "secret")
}
func TestLookup(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v3/series/lookup" || r.URL.Query().Get("term") != "breaking" {
t.Errorf("unexpected request %s", r.URL)
}
if r.Header.Get("X-Api-Key") != "secret" {
t.Error("missing api key header")
}
w.Write([]byte(lookupJSON))
})
series, err := c.Lookup(context.Background(), "breaking")
if err != nil {
t.Fatal(err)
}
if len(series) != 2 || series[0].InLibrary() || !series[1].InLibrary() {
t.Fatalf("series = %+v", series)
}
if series[0].SeasonCount() != 2 {
t.Errorf("SeasonCount = %d, want 2 (specials excluded)", series[0].SeasonCount())
}
if series[0].Ratings.Value != 9.3 || series[0].Network != "AMC" {
t.Errorf("fields not decoded: %+v", series[0])
}
}
func TestAddSeries(t *testing.T) {
var got map[string]any
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/api/v3/series" {
t.Errorf("unexpected request %s %s", r.Method, r.URL)
}
json.NewDecoder(r.Body).Decode(&got)
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{"id":3,"title":"Breaking Bad","tvdbId":81189}`))
})
s := Series{
Title: "Breaking Bad", Year: 2008, TVDBID: 81189, SeriesType: "standard",
Seasons: []Season{{SeasonNumber: 1}, {SeasonNumber: 2}},
}
created, err := c.AddSeries(context.Background(), s, AddOptions{
QualityProfileID: 4, RootFolderPath: "/tv", Monitored: true,
Monitor: MonitorFirstSeason, SearchNow: true,
})
if err != nil {
t.Fatal(err)
}
if created.ID != 3 {
t.Errorf("id = %d", created.ID)
}
if got["tvdbId"] != float64(81189) || got["qualityProfileId"] != float64(4) ||
got["rootFolderPath"] != "/tv" || got["monitored"] != true || got["seasonFolder"] != true {
t.Errorf("request body = %v", got)
}
ao, _ := got["addOptions"].(map[string]any)
if ao["monitor"] != "firstSeason" || ao["searchForMissingEpisodes"] != true {
t.Errorf("addOptions = %v", ao)
}
if seasons, _ := got["seasons"].([]any); len(seasons) != 2 {
t.Errorf("seasons = %v", got["seasons"])
}
}
func TestAddSeriesDefaultMonitor(t *testing.T) {
var got map[string]any
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
json.NewDecoder(r.Body).Decode(&got)
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{}`))
})
if _, err := c.AddSeries(context.Background(), Series{}, AddOptions{}); err != nil {
t.Fatal(err)
}
if ao, _ := got["addOptions"].(map[string]any); ao["monitor"] != "all" {
t.Errorf("addOptions = %v", ao)
}
}
func TestSearchSeries(t *testing.T) {
var got map[string]any
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
json.NewDecoder(r.Body).Decode(&got)
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{}`))
})
if err := c.SearchSeries(context.Background(), 3); err != nil {
t.Fatal(err)
}
if got["name"] != "SeriesSearch" || got["seriesId"] != float64(3) {
t.Errorf("body = %v", got)
}
}