// Package sonarr provides the small read-only slice of Sonarr used by the home screen. package sonarr import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" ) type Client struct { baseURL string apiKey string http *http.Client } type Image struct { CoverType string `json:"coverType"` URL string `json:"url"` RemoteURL string `json:"remoteUrl"` } type Series struct { ID int `json:"id"` TVDBID int `json:"tvdbId"` Title string `json:"title"` TitleSlug string `json:"titleSlug"` Overview string `json:"overview"` Year int `json:"year"` Network string `json:"network"` Genres []string `json:"genres"` Images []Image `json:"images"` RootFolderPath string `json:"rootFolderPath,omitempty"` QualityProfileID int `json:"qualityProfileId,omitempty"` Monitored bool `json:"monitored"` SeasonFolder bool `json:"seasonFolder"` Seasons []Season `json:"seasons"` Status string `json:"status"` NextAiring *time.Time `json:"nextAiring"` } // Series returns Sonarr's current catalogue, including lifecycle and next-airing data. func (c *Client) Series(ctx context.Context) ([]Series, error) { var series []Series if err := c.get(ctx, "/api/v3/series", &series); err != nil { return nil, err } return series, nil } // Episodes returns Sonarr's complete episode list for one series. Unlike the calendar, // this includes future episodes, which is what makes finale detection trustworthy rather // than mistaking the newest downloaded episode for the end of a season. func (c *Client) Episodes(ctx context.Context, seriesID int) ([]Episode, error) { if seriesID <= 0 { return nil, fmt.Errorf("sonarr: invalid series id") } req, err := c.request(ctx, "/api/v3/episode", url.Values{ "seriesId": {strconv.Itoa(seriesID)}, "includeSeries": {"true"}, }) if err != nil { return nil, err } var episodes []Episode if err := c.do(req, &episodes); err != nil { return nil, err } return episodes, nil } type Season struct { SeasonNumber int `json:"seasonNumber"` Monitored bool `json:"monitored"` } type RootFolder struct { Path string `json:"path"` } type QualityProfile struct { ID int `json:"id"` Name string `json:"name"` } // RequestOptions are Memby's deliberate request policy. They must be supplied by the // gateway: letting Sonarr choose a profile or search flag reintroduces unsafe defaults. type RequestOptions struct { QualityProfileID int SearchImmediately bool } func (c *Client) RootFolders(ctx context.Context) ([]RootFolder, error) { var roots []RootFolder if err := c.get(ctx, "/api/v3/rootfolder", &roots); err != nil { return nil, err } return roots, nil } func (c *Client) QualityProfiles(ctx context.Context) ([]QualityProfile, error) { var profiles []QualityProfile if err := c.get(ctx, "/api/v3/qualityprofile", &profiles); err != nil { return nil, err } return profiles, nil } type EpisodeFile struct { DateAdded *time.Time `json:"dateAdded"` } type Episode struct { ID int `json:"id"` SeriesID int `json:"seriesId"` SeasonNumber int `json:"seasonNumber"` EpisodeNumber int `json:"episodeNumber"` // Sonarr names confirmed endings (for example "seasonFinale" and // "seriesFinale"). Empty means it has made no finale claim. FinaleType string `json:"finaleType"` Title string `json:"title"` Overview string `json:"overview"` AirDateUTC *time.Time `json:"airDateUtc"` Runtime int `json:"runtime"` HasFile bool `json:"hasFile"` Monitored bool `json:"monitored"` Grabbed bool `json:"grabbed"` Series Series `json:"series"` EpisodeFile *EpisodeFile `json:"episodeFile"` } // SearchEpisode asks Sonarr to search exactly one episode. It deliberately uses the // episode ids command rather than a series command, so a replacement cannot search a // season or the show's backlog. func (c *Client) SearchEpisode(ctx context.Context, episodeID int) error { if episodeID <= 0 { return fmt.Errorf("sonarr: invalid episode id") } return c.post(ctx, "/api/v3/command", map[string]any{ "name": "EpisodeSearch", "episodeIds": []int{episodeID}, }, &struct{}{}) } type APIError struct { StatusCode int Body string } func (e *APIError) Error() string { return fmt.Sprintf("sonarr: status %d: %s", e.StatusCode, e.Body) } func New(baseURL, apiKey string, timeout time.Duration) *Client { return &Client{ baseURL: strings.TrimRight(baseURL, "/"), apiKey: apiKey, http: &http.Client{ Timeout: timeout, Transport: &http.Transport{ MaxIdleConns: 20, MaxIdleConnsPerHost: 10, IdleConnTimeout: 90 * time.Second, }, }, } } // Calendar returns episodes in [start, end), including series artwork and imported-file // details so Memby can distinguish upcoming, downloading and already-added episodes. func (c *Client) Calendar(ctx context.Context, start, end time.Time) ([]Episode, error) { params := url.Values{ "start": {start.UTC().Format(time.RFC3339Nano)}, "end": {end.UTC().Format(time.RFC3339Nano)}, "unmonitored": {"true"}, "includeSeries": {"true"}, "includeEpisodeFile": {"true"}, "includeEpisodeImages": {"true"}, } req, err := c.request(ctx, "/api/v3/calendar", params) if err != nil { return nil, err } var episodes []Episode if err := c.do(req, &episodes); err != nil { return nil, err } return episodes, nil } func (c *Client) Lookup(ctx context.Context, term string) ([]Series, error) { req, err := c.request(ctx, "/api/v3/series/lookup", url.Values{"term": {term}}) if err != nil { return nil, err } var series []Series if err := c.do(req, &series); err != nil { return nil, err } return series, nil } // AddRequested adds a monitored series using the supplied request policy. It never reads // Sonarr's first quality profile, because that is commonly "Any". func (c *Client) AddRequested(ctx context.Context, series Series, rootFolder string, options RequestOptions) (Series, error) { if strings.TrimSpace(rootFolder) == "" { return Series{}, fmt.Errorf("sonarr: request root folder is required") } if options.QualityProfileID <= 0 { return Series{}, fmt.Errorf("sonarr: request quality profile is required") } series.ID = 0 series.RootFolderPath = rootFolder series.QualityProfileID = options.QualityProfileID series.Monitored = true series.SeasonFolder = true for i := range series.Seasons { series.Seasons[i].Monitored = true } body := struct { Series AddOptions map[string]bool `json:"addOptions"` }{Series: series, AddOptions: map[string]bool{"searchForMissingEpisodes": options.SearchImmediately}} var added Series if err := c.post(ctx, "/api/v3/series", body, &added); err != nil { return Series{}, err } return added, nil } // MediaCover fetches a series poster or fanart without exposing the Sonarr API key. func (c *Client) MediaCover(ctx context.Context, seriesID int, coverType string) (*http.Response, error) { if seriesID <= 0 || (coverType != "poster" && coverType != "fanart") { return nil, fmt.Errorf("sonarr: invalid media cover") } path := "/MediaCover/" + strconv.Itoa(seriesID) + "/" + coverType + ".jpg" req, err := c.request(ctx, path, nil) if err != nil { return nil, err } req.Header.Set("Accept", "image/*") resp, err := c.http.Do(req) if err != nil { return nil, fmt.Errorf("sonarr: %w", err) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { defer resp.Body.Close() body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10)) return nil, &APIError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(body))} } return resp, nil } func (c *Client) request(ctx context.Context, path string, params url.Values) (*http.Request, error) { endpoint := c.baseURL + path if len(params) > 0 { endpoint += "?" + params.Encode() } req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } req.Header.Set("X-Api-Key", c.apiKey) req.Header.Set("Accept", "application/json") return req, nil } func (c *Client) get(ctx context.Context, path string, out any) error { req, err := c.request(ctx, path, nil) if err != nil { return err } return c.do(req, out) } func (c *Client) post(ctx context.Context, path string, body, out any) error { raw, err := json.Marshal(body) if err != nil { return fmt.Errorf("sonarr: encode request: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, strings.NewReader(string(raw))) if err != nil { return err } req.Header.Set("X-Api-Key", c.apiKey) req.Header.Set("Accept", "application/json") req.Header.Set("Content-Type", "application/json") return c.do(req, out) } func (c *Client) do(req *http.Request, out any) error { resp, err := c.http.Do(req) if err != nil { return fmt.Errorf("sonarr: %w", err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10)) return &APIError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(body))} } if err := json.NewDecoder(resp.Body).Decode(out); err != nil { return fmt.Errorf("sonarr: decode response: %w", err) } return nil } // QueueItem is one episode the download client is working on. See radarr.QueueItem for why // three status words are carried rather than one; Sonarr describes a download in the same // vocabulary. // // SeriesID is what the request page joins on: a viewer asks for a show, and what comes back // is some number of episodes of it. type QueueItem struct { ID int `json:"id"` SeriesID int `json:"seriesId"` EpisodeID int `json:"episodeId"` Size float64 `json:"size"` Sizeleft float64 `json:"sizeleft"` Timeleft string `json:"timeleft"` Status string `json:"status"` TrackedDownloadState string `json:"trackedDownloadState"` TrackedDownloadStatus string `json:"trackedDownloadStatus"` ErrorMessage string `json:"errorMessage"` } // queuePageSize is what one read asks for; see radarr's own note. const queuePageSize = 200 type queuePage struct { Records []QueueItem `json:"records"` } // Queue returns what Sonarr is currently working on, excluding downloads it cannot match to // a series it tracks. func (c *Client) Queue(ctx context.Context) ([]QueueItem, error) { req, err := c.request(ctx, "/api/v3/queue", url.Values{ "pageSize": {strconv.Itoa(queuePageSize)}, "includeUnknownSeriesItems": {"false"}, }) if err != nil { return nil, err } var page queuePage if err := c.do(req, &page); err != nil { return nil, err } return page.Records, nil }