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
+163
View File
@@ -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)
}