// 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"` Title string `json:"title"` Overview string `json:"overview"` Year int `json:"year"` Network string `json:"network"` Genres []string `json:"genres"` Images []Image `json:"images"` } 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"` 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"` } 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 } // 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) 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 }