// Package sonarr is a client for the Sonarr v3/v4 API. package sonarr import ( "context" "encoding/json" "net/http" "net/url" "strconv" "time" "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 Statistics `json:"statistics"` // Images are the season's posters, only filled by GetSeries; Sonarr keeps // no copy of them, so they only have a remote URL (TheTVDB). Images []arr.Image `json:"images,omitempty"` } // 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"` SizeOnDisk int64 `json:"sizeOnDisk"` } // 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 { return c.Command(ctx, map[string]any{"name": "SeriesSearch", "seriesId": seriesID}) } // SearchSeason triggers an automatic search for the missing episodes of one season. func (c *Client) SearchSeason(ctx context.Context, seriesID, season int) error { return c.Command(ctx, map[string]any{"name": "SeasonSearch", "seriesId": seriesID, "seasonNumber": season}) } // SearchEpisode triggers an automatic search for one episode. func (c *Client) SearchEpisode(ctx context.Context, episodeID int) error { return c.Command(ctx, map[string]any{"name": "EpisodeSearch", "episodeIds": []int{episodeID}}) } // GetSeries fetches one library series. func (c *Client) GetSeries(ctx context.Context, id int) (Series, error) { var s Series // Season posters are only sent when asked for. err := c.Do(ctx, http.MethodGet, "series/"+strconv.Itoa(id), url.Values{"includeSeasonImages": {"true"}}, nil, &s) return s, err } // SetMonitored changes the monitored flag of a library series. func (c *Client) SetMonitored(ctx context.Context, seriesID int, monitored bool) error { return c.Update(ctx, "series", seriesID, func(o map[string]json.RawMessage) error { arr.SetBool(o, "monitored", monitored) return nil }) } // SetSeasonMonitored changes the monitored flag of one season of a library series. func (c *Client) SetSeasonMonitored(ctx context.Context, seriesID, season int, monitored bool) error { return c.Update(ctx, "series", seriesID, func(o map[string]json.RawMessage) error { return arr.SetSeasonMonitored(o, season, monitored) }) } // SetEpisodesMonitored changes the monitored flag of episodes. func (c *Client) SetEpisodesMonitored(ctx context.Context, episodeIDs []int, monitored bool) error { body := map[string]any{"episodeIds": episodeIDs, "monitored": monitored} return c.Do(ctx, http.MethodPut, "episode/monitor", nil, body, nil) } // Queue lists Sonarr's download queue, including series and episode info. func (c *Client) Queue(ctx context.Context) ([]arr.QueueItem, error) { return c.QueueWith(ctx, url.Values{"includeSeries": {"true"}, "includeEpisode": {"true"}}) } // Episode is an episode of a library series. type Episode struct { ID int `json:"id"` SeriesID int `json:"seriesId"` SeasonNumber int `json:"seasonNumber"` EpisodeNumber int `json:"episodeNumber"` Title string `json:"title"` AirDate string `json:"airDate"` AirDateUTC string `json:"airDateUtc"` Overview string `json:"overview,omitempty"` HasFile bool `json:"hasFile"` Monitored bool `json:"monitored"` Series *Series `json:"series,omitempty"` // only with includeSeries } // Episodes lists all episodes of a library series. func (c *Client) Episodes(ctx context.Context, seriesID int) ([]Episode, error) { var episodes []Episode err := c.Do(ctx, http.MethodGet, "episode", url.Values{"seriesId": {strconv.Itoa(seriesID)}}, nil, &episodes) return episodes, err } // EpisodeReleases runs an interactive search for one episode. func (c *Client) EpisodeReleases(ctx context.Context, episodeID int) ([]arr.Release, error) { return c.Client.Releases(ctx, url.Values{"episodeId": {strconv.Itoa(episodeID)}}) } // SeasonReleases runs an interactive search for a whole season (season packs and episodes). func (c *Client) SeasonReleases(ctx context.Context, seriesID, season int) ([]arr.Release, error) { return c.Client.Releases(ctx, url.Values{ "seriesId": {strconv.Itoa(seriesID)}, "seasonNumber": {strconv.Itoa(season)}, }) } // EpisodeFiles lists the downloaded files of a library series. func (c *Client) EpisodeFiles(ctx context.Context, seriesID int) ([]arr.MediaFile, error) { var files []arr.MediaFile err := c.Do(ctx, http.MethodGet, "episodefile", url.Values{"seriesId": {strconv.Itoa(seriesID)}}, nil, &files) return files, err } // DeleteSeries removes a series from the library. With deleteFiles all its // episode files are deleted from disk too; addExclusion keeps it from coming // back through import lists. func (c *Client) DeleteSeries(ctx context.Context, id int, deleteFiles, addExclusion bool) error { q := url.Values{ "deleteFiles": {strconv.FormatBool(deleteFiles)}, "addImportListExclusion": {strconv.FormatBool(addExclusion)}, } return c.Do(ctx, http.MethodDelete, "series/"+strconv.Itoa(id), q, nil, nil) } // Calendar lists the episodes airing between start and end, both inclusive, // with their series. Unmonitored episodes are only included when asked for. func (c *Client) Calendar(ctx context.Context, start, end time.Time, unmonitored bool) ([]Episode, error) { q := url.Values{ "start": {start.Format("2006-01-02")}, "end": {end.Format("2006-01-02")}, "unmonitored": {strconv.FormatBool(unmonitored)}, "includeSeries": {"true"}, } var episodes []Episode err := c.Do(ctx, http.MethodGet, "calendar", q, nil, &episodes) return episodes, err }