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:
@@ -0,0 +1,163 @@
|
||||
// Package sonarr is a client for the Sonarr v3/v4 API.
|
||||
package sonarr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
|
||||
)
|
||||
|
||||
// Client talks to a Sonarr instance. The embedded arr.Client provides
|
||||
// SystemStatus, QualityProfiles and RootFolders.
|
||||
type Client struct {
|
||||
*arr.Client
|
||||
}
|
||||
|
||||
// NewClient returns a Sonarr client.
|
||||
func NewClient(baseURL, apiKey string) *Client {
|
||||
return &Client{arr.NewClient(baseURL, apiKey)}
|
||||
}
|
||||
|
||||
// Monitor modes accepted by addOptions.monitor.
|
||||
const (
|
||||
MonitorAll = "all"
|
||||
MonitorFuture = "future"
|
||||
MonitorMissing = "missing"
|
||||
MonitorExisting = "existing"
|
||||
MonitorFirstSeason = "firstSeason"
|
||||
MonitorLatestSeason = "latestSeason"
|
||||
MonitorPilot = "pilot"
|
||||
MonitorNone = "none"
|
||||
)
|
||||
|
||||
// Rating is a series rating.
|
||||
type Rating struct {
|
||||
Votes int `json:"votes"`
|
||||
Value float64 `json:"value"`
|
||||
}
|
||||
|
||||
// Season is a season entry of a series.
|
||||
type Season struct {
|
||||
SeasonNumber int `json:"seasonNumber"`
|
||||
Monitored bool `json:"monitored"`
|
||||
}
|
||||
|
||||
// Statistics are the episode counts Sonarr reports for library series.
|
||||
type Statistics struct {
|
||||
SeasonCount int `json:"seasonCount"`
|
||||
EpisodeFileCount int `json:"episodeFileCount"`
|
||||
EpisodeCount int `json:"episodeCount"`
|
||||
TotalEpisodeCount int `json:"totalEpisodeCount"`
|
||||
}
|
||||
|
||||
// Series is a lookup result or library series. ID is 0 when the series is
|
||||
// not in the library yet.
|
||||
type Series struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Year int `json:"year"`
|
||||
Overview string `json:"overview,omitempty"`
|
||||
Network string `json:"network,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Runtime int `json:"runtime"`
|
||||
Genres []string `json:"genres,omitempty"`
|
||||
TVDBID int `json:"tvdbId"`
|
||||
TMDBID int `json:"tmdbId,omitempty"`
|
||||
IMDBID string `json:"imdbId,omitempty"`
|
||||
TitleSlug string `json:"titleSlug,omitempty"`
|
||||
SeriesType string `json:"seriesType,omitempty"`
|
||||
Images []arr.Image `json:"images,omitempty"`
|
||||
Ratings Rating `json:"ratings"`
|
||||
Seasons []Season `json:"seasons,omitempty"`
|
||||
Monitored bool `json:"monitored"`
|
||||
Statistics Statistics `json:"statistics"`
|
||||
}
|
||||
|
||||
// InLibrary reports whether the series has already been added to Sonarr.
|
||||
func (s Series) InLibrary() bool { return s.ID != 0 }
|
||||
|
||||
// SeasonCount returns the number of regular seasons (excluding specials).
|
||||
func (s Series) SeasonCount() int {
|
||||
n := 0
|
||||
for _, season := range s.Seasons {
|
||||
if season.SeasonNumber > 0 {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// Lookup searches for series by free text, "tvdb:81189" or "imdb:tt0903747".
|
||||
func (c *Client) Lookup(ctx context.Context, term string) ([]Series, error) {
|
||||
var series []Series
|
||||
err := c.Do(ctx, http.MethodGet, "series/lookup", url.Values{"term": {term}}, nil, &series)
|
||||
return series, err
|
||||
}
|
||||
|
||||
// Series lists the series in the library.
|
||||
func (c *Client) Series(ctx context.Context) ([]Series, error) {
|
||||
var series []Series
|
||||
err := c.Do(ctx, http.MethodGet, "series", nil, nil, &series)
|
||||
return series, err
|
||||
}
|
||||
|
||||
// AddOptions are the choices from the add form.
|
||||
type AddOptions struct {
|
||||
QualityProfileID int
|
||||
RootFolderPath string
|
||||
Monitored bool
|
||||
Monitor string // one of the Monitor* constants; defaults to MonitorAll
|
||||
SearchNow bool // search for missing episodes after adding
|
||||
}
|
||||
|
||||
type addSeriesRequest struct {
|
||||
Title string `json:"title"`
|
||||
Year int `json:"year"`
|
||||
TVDBID int `json:"tvdbId"`
|
||||
TitleSlug string `json:"titleSlug,omitempty"`
|
||||
SeriesType string `json:"seriesType,omitempty"`
|
||||
Images []arr.Image `json:"images,omitempty"`
|
||||
Seasons []Season `json:"seasons,omitempty"`
|
||||
QualityProfileID int `json:"qualityProfileId"`
|
||||
RootFolderPath string `json:"rootFolderPath"`
|
||||
Monitored bool `json:"monitored"`
|
||||
SeasonFolder bool `json:"seasonFolder"`
|
||||
AddOptions struct {
|
||||
Monitor string `json:"monitor"`
|
||||
SearchForMissingEpisodes bool `json:"searchForMissingEpisodes"`
|
||||
} `json:"addOptions"`
|
||||
}
|
||||
|
||||
// AddSeries adds a lookup result to Sonarr and returns the created series.
|
||||
func (c *Client) AddSeries(ctx context.Context, s Series, opts AddOptions) (Series, error) {
|
||||
req := addSeriesRequest{
|
||||
Title: s.Title,
|
||||
Year: s.Year,
|
||||
TVDBID: s.TVDBID,
|
||||
TitleSlug: s.TitleSlug,
|
||||
SeriesType: s.SeriesType,
|
||||
Images: s.Images,
|
||||
Seasons: s.Seasons,
|
||||
QualityProfileID: opts.QualityProfileID,
|
||||
RootFolderPath: opts.RootFolderPath,
|
||||
Monitored: opts.Monitored,
|
||||
SeasonFolder: true,
|
||||
}
|
||||
req.AddOptions.Monitor = opts.Monitor
|
||||
if req.AddOptions.Monitor == "" {
|
||||
req.AddOptions.Monitor = MonitorAll
|
||||
}
|
||||
req.AddOptions.SearchForMissingEpisodes = opts.SearchNow
|
||||
|
||||
var created Series
|
||||
err := c.Do(ctx, http.MethodPost, "series", nil, req, &created)
|
||||
return created, err
|
||||
}
|
||||
|
||||
// SearchSeries triggers an automatic search for all missing episodes of a library series.
|
||||
func (c *Client) SearchSeries(ctx context.Context, seriesID int) error {
|
||||
body := map[string]any{"name": "SeriesSearch", "seriesId": seriesID}
|
||||
return c.Do(ctx, http.MethodPost, "command", nil, body, nil)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user