// Package radarr provides the small read-only slice of Radarr used by the home screen. package radarr 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 MovieFile struct { DateAdded *time.Time `json:"dateAdded"` } type Movie struct { ID int `json:"id"` TMDBID int `json:"tmdbId"` Title string `json:"title"` TitleSlug string `json:"titleSlug"` Overview string `json:"overview"` Year int `json:"year"` Runtime int `json:"runtime"` Genres []string `json:"genres"` Images []Image `json:"images"` DigitalRelease *time.Time `json:"digitalRelease"` PhysicalRelease *time.Time `json:"physicalRelease"` InCinemas *time.Time `json:"inCinemas"` // Radarr's own lifecycle word for the title: tba, announced, inCinemas, released, // deleted. It is what the schedule card's lifecycle tag says. Status string `json:"status"` HasFile bool `json:"hasFile"` Monitored bool `json:"monitored"` MovieFile *MovieFile `json:"movieFile"` RootFolderPath string `json:"rootFolderPath,omitempty"` QualityProfileID int `json:"qualityProfileId,omitempty"` MinimumAvailability string `json:"minimumAvailability,omitempty"` } type RootFolder struct { Path string `json:"path"` } type QualityProfile struct { ID int `json:"id"` } type APIError struct { StatusCode int Body string } func (e *APIError) Error() string { return fmt.Sprintf("radarr: 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 movies whose Radarr calendar dates intersect [start, end). Memby // selects actual digital releases and its cinema-date fallback after fetching the data. func (c *Client) Calendar(ctx context.Context, start, end time.Time) ([]Movie, error) { params := url.Values{ "start": {start.UTC().Format(time.RFC3339Nano)}, "end": {end.UTC().Format(time.RFC3339Nano)}, "unmonitored": {"true"}, } req, err := c.request(ctx, "/api/v3/calendar", params) if err != nil { return nil, err } var movies []Movie if err := c.do(req, &movies); err != nil { return nil, err } return movies, nil } func (c *Client) Lookup(ctx context.Context, term string) ([]Movie, error) { req, err := c.request(ctx, "/api/v3/movie/lookup", url.Values{"term": {term}}) if err != nil { return nil, err } var movies []Movie if err := c.do(req, &movies); err != nil { return nil, err } return movies, nil } // AddRequested adds a title, monitors it and asks Radarr to search for it immediately. // A request that merely creates an unmonitored catalogue row never reaches a downloader, // which is indistinguishable from a broken button to the viewer who made it. func (c *Client) AddRequested(ctx context.Context, movie Movie) (Movie, error) { var roots []RootFolder if err := c.get(ctx, "/api/v3/rootfolder", &roots); err != nil { return Movie{}, err } var profiles []QualityProfile if err := c.get(ctx, "/api/v3/qualityprofile", &profiles); err != nil { return Movie{}, err } if len(roots) == 0 || len(profiles) == 0 { return Movie{}, fmt.Errorf("radarr: no root folder or quality profile configured") } movie.ID = 0 movie.RootFolderPath = roots[0].Path movie.QualityProfileID = profiles[0].ID movie.Monitored = true body := struct { Movie AddOptions map[string]bool `json:"addOptions"` }{Movie: movie, AddOptions: map[string]bool{"searchForMovie": true}} var added Movie if err := c.post(ctx, "/api/v3/movie", body, &added); err != nil { return Movie{}, err } return added, nil } // MediaCover fetches a movie poster or fanart without exposing the Radarr API key. func (c *Client) MediaCover(ctx context.Context, movieID int, coverType string) (*http.Response, error) { if movieID <= 0 || (coverType != "poster" && coverType != "fanart") { return nil, fmt.Errorf("radarr: invalid media cover") } path := "/MediaCover/" + strconv.Itoa(movieID) + "/" + 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("radarr: %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("radarr: 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("radarr: %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("radarr: decode response: %w", err) } return nil } // Movies returns Radarr's whole catalogue. // // The request page needs the current state of every title a viewer has ever asked for, and // asking Radarr per title would be one round trip per card. One catalogue read answers them // all; the caller caches it. func (c *Client) Movies(ctx context.Context) ([]Movie, error) { var movies []Movie if err := c.get(ctx, "/api/v3/movie", &movies); err != nil { return nil, err } return movies, nil }