2026-07-29 15:26:40 +12:00
|
|
|
// 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 {
|
2026-08-02 22:10:19 +12:00
|
|
|
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 {
|
2026-08-14 10:30:25 +12:00
|
|
|
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
|
2026-07-29 15:26:40 +12:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type EpisodeFile struct {
|
|
|
|
|
DateAdded *time.Time `json:"dateAdded"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type Episode struct {
|
2026-08-11 12:08:51 +12:00
|
|
|
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"`
|
2026-07-29 15:26:40 +12:00
|
|
|
}
|
|
|
|
|
|
2026-08-14 13:32:14 +12:00
|
|
|
// 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{}{})
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 15:26:40 +12:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 22:10:19 +12:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-14 10:30:25 +12:00
|
|
|
// 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")
|
2026-08-02 22:10:19 +12:00
|
|
|
}
|
2026-08-14 10:30:25 +12:00
|
|
|
if options.QualityProfileID <= 0 {
|
|
|
|
|
return Series{}, fmt.Errorf("sonarr: request quality profile is required")
|
2026-08-02 22:10:19 +12:00
|
|
|
}
|
|
|
|
|
series.ID = 0
|
2026-08-14 10:30:25 +12:00
|
|
|
series.RootFolderPath = rootFolder
|
|
|
|
|
series.QualityProfileID = options.QualityProfileID
|
2026-08-12 09:57:56 +12:00
|
|
|
series.Monitored = true
|
2026-08-02 22:10:19 +12:00
|
|
|
series.SeasonFolder = true
|
|
|
|
|
for i := range series.Seasons {
|
2026-08-12 09:57:56 +12:00
|
|
|
series.Seasons[i].Monitored = true
|
2026-08-02 22:10:19 +12:00
|
|
|
}
|
|
|
|
|
body := struct {
|
|
|
|
|
Series
|
|
|
|
|
AddOptions map[string]bool `json:"addOptions"`
|
2026-08-14 10:30:25 +12:00
|
|
|
}{Series: series, AddOptions: map[string]bool{"searchForMissingEpisodes": options.SearchImmediately}}
|
2026-08-02 22:10:19 +12:00
|
|
|
var added Series
|
|
|
|
|
if err := c.post(ctx, "/api/v3/series", body, &added); err != nil {
|
|
|
|
|
return Series{}, err
|
|
|
|
|
}
|
|
|
|
|
return added, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 15:26:40 +12:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 22:10:19 +12:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 15:26:40 +12:00
|
|
|
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
|
|
|
|
|
}
|