Files
memby/server/internal/sonarr/client.go
T

275 lines
8.0 KiB
Go

// 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"`
}
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
}
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
}
// AddUnmonitored adds a series without monitoring it or starting an episode search.
func (c *Client) AddUnmonitored(ctx context.Context, series Series) (Series, error) {
var roots []RootFolder
if err := c.get(ctx, "/api/v3/rootfolder", &roots); err != nil {
return Series{}, err
}
var profiles []QualityProfile
if err := c.get(ctx, "/api/v3/qualityprofile", &profiles); err != nil {
return Series{}, err
}
if len(roots) == 0 || len(profiles) == 0 {
return Series{}, fmt.Errorf("sonarr: no root folder or quality profile configured")
}
series.ID = 0
series.RootFolderPath = roots[0].Path
series.QualityProfileID = profiles[0].ID
series.Monitored = false
series.SeasonFolder = true
for i := range series.Seasons {
series.Seasons[i].Monitored = false
}
body := struct {
Series
AddOptions map[string]bool `json:"addOptions"`
}{Series: series, AddOptions: map[string]bool{"searchForMissingEpisodes": false}}
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
}