344 lines
12 KiB
Go
344 lines
12 KiB
Go
// 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"`
|
|
// Metadata Radarr carries for a film the household does not hold yet, and which
|
|
// therefore has no Emby record to read it from. It is the whole substance of the
|
|
// Radarr-only detail page; the schedule card itself uses none of it.
|
|
OriginalTitle string `json:"originalTitle,omitempty"`
|
|
Studio string `json:"studio,omitempty"`
|
|
Certification string `json:"certification,omitempty"`
|
|
YouTubeTrailerID string `json:"youTubeTrailerId,omitempty"`
|
|
IMDBID string `json:"imdbId,omitempty"`
|
|
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"`
|
|
}
|
|
|
|
// SearchMovie asks Radarr to search exactly one tracked film. It does not delete or
|
|
// unmonitor the existing file; Radarr retains it until its normal import policy wins.
|
|
func (c *Client) SearchMovie(ctx context.Context, movieID int) error {
|
|
if movieID <= 0 {
|
|
return fmt.Errorf("radarr: invalid movie id")
|
|
}
|
|
return c.post(ctx, "/api/v3/command", map[string]any{
|
|
"name": "MoviesSearch", "movieIds": []int{movieID},
|
|
}, &struct{}{})
|
|
}
|
|
|
|
type RootFolder struct {
|
|
Path string `json:"path"`
|
|
}
|
|
|
|
type QualityProfile struct {
|
|
ID int `json:"id"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
// RequestOptions are Memby's deliberate movie-request policy. Radarr defaults are never
|
|
// allowed to choose a profile or initiate a search on Memby's behalf.
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// Movie is one tracked film, for the case the cached catalogue cannot answer: a title
|
|
// added to Radarr since the catalogue was last read. The catalogue is still tried first —
|
|
// this is the fallback, not the ordinary path, because a detail page opening must not cost
|
|
// a round trip Radarr has already answered once for the whole household.
|
|
func (c *Client) Movie(ctx context.Context, movieID int) (Movie, error) {
|
|
if movieID <= 0 {
|
|
return Movie{}, fmt.Errorf("radarr: invalid movie id")
|
|
}
|
|
var movie Movie
|
|
if err := c.get(ctx, "/api/v3/movie/"+strconv.Itoa(movieID), &movie); err != nil {
|
|
return Movie{}, err
|
|
}
|
|
return movie, 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 monitored movie using the supplied request policy.
|
|
func (c *Client) AddRequested(ctx context.Context, movie Movie, rootFolder string, options RequestOptions) (Movie, error) {
|
|
if strings.TrimSpace(rootFolder) == "" {
|
|
return Movie{}, fmt.Errorf("radarr: request root folder is required")
|
|
}
|
|
if options.QualityProfileID <= 0 {
|
|
return Movie{}, fmt.Errorf("radarr: request quality profile is required")
|
|
}
|
|
movie.ID = 0
|
|
movie.RootFolderPath = rootFolder
|
|
movie.QualityProfileID = options.QualityProfileID
|
|
movie.Monitored = true
|
|
body := struct {
|
|
Movie
|
|
AddOptions map[string]bool `json:"addOptions"`
|
|
}{Movie: movie, AddOptions: map[string]bool{"searchForMovie": options.SearchImmediately}}
|
|
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
|
|
}
|
|
|
|
// QueueItem is one thing the download client is working on, in the narrow shape the
|
|
// request page needs: which film, how far through, and how it is going.
|
|
//
|
|
// Radarr describes a download in three overlapping words rather than one, and all three
|
|
// are needed. Status is the *download client's* view (queued, downloading, paused,
|
|
// completed, failed, warning, delay). TrackedDownloadState is Radarr's own view of what
|
|
// happens after the bytes land (downloading, importPending, importing, imported,
|
|
// failedPending, failed) — which is the only thing that separates "still coming down the
|
|
// wire" from "almost on the shelf". TrackedDownloadStatus is the verdict (ok, warning,
|
|
// error), and is what says an otherwise healthy-looking row has actually gone wrong.
|
|
type QueueItem struct {
|
|
ID int `json:"id"`
|
|
MovieID int `json:"movieId"`
|
|
// Size and Sizeleft are bytes, as floats — Radarr sends them that way, and a film is
|
|
// comfortably past what a 32-bit int holds.
|
|
Size float64 `json:"size"`
|
|
Sizeleft float64 `json:"sizeleft"`
|
|
// Timeleft is the download client's own estimate, formatted "00:14:32" or
|
|
// "1.02:03:04". It is absent for a queued or stalled item, which is exactly the case
|
|
// where Memby must not invent one.
|
|
Timeleft string `json:"timeleft"`
|
|
Status string `json:"status"`
|
|
TrackedDownloadState string `json:"trackedDownloadState"`
|
|
TrackedDownloadStatus string `json:"trackedDownloadStatus"`
|
|
ErrorMessage string `json:"errorMessage"`
|
|
}
|
|
|
|
// queuePageSize is what one read asks for. The queue is what the household is downloading
|
|
// right now, so it is small by nature; the cap exists so a download client that has wedged
|
|
// with a thousand rows cannot turn a request-page refresh into a large response.
|
|
const queuePageSize = 200
|
|
|
|
type queuePage struct {
|
|
Records []QueueItem `json:"records"`
|
|
}
|
|
|
|
// Queue returns what Radarr is currently working on.
|
|
//
|
|
// Unknown items are excluded: those are downloads in the client that Radarr cannot match
|
|
// to a film it tracks, so they can never be the answer to "what is happening to the thing
|
|
// I asked for" and would only be rows nothing could use.
|
|
func (c *Client) Queue(ctx context.Context) ([]QueueItem, error) {
|
|
req, err := c.request(ctx, "/api/v3/queue", url.Values{
|
|
"pageSize": {strconv.Itoa(queuePageSize)},
|
|
"includeUnknownMovieItems": {"false"},
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var page queuePage
|
|
if err := c.do(req, &page); err != nil {
|
|
return nil, err
|
|
}
|
|
return page.Records, nil
|
|
}
|