// Package radarr is a client for the Radarr v3 API. package radarr import ( "context" "encoding/json" "net/http" "net/url" "strconv" "time" "git.stinnesbeck.com/nils/arr-tui/internal/arr" ) // Client talks to a Radarr instance. The embedded arr.Client provides // SystemStatus, QualityProfiles and RootFolders. type Client struct { *arr.Client } // NewClient returns a Radarr client. func NewClient(baseURL, apiKey string) *Client { return &Client{arr.NewClient(baseURL, apiKey)} } // Ratings holds the per-source ratings Radarr reports. type Ratings struct { IMDB *Rating `json:"imdb,omitempty"` TMDB *Rating `json:"tmdb,omitempty"` Metacritic *Rating `json:"metacritic,omitempty"` RottenTomatoes *Rating `json:"rottenTomatoes,omitempty"` } // Rating is a single rating value. type Rating struct { Votes int `json:"votes"` Value float64 `json:"value"` } // Movie is a lookup result or library movie. ID is 0 when the movie is not // in the library yet. type Movie struct { ID int `json:"id"` Title string `json:"title"` OriginalTitle string `json:"originalTitle,omitempty"` Year int `json:"year"` Overview string `json:"overview,omitempty"` Runtime int `json:"runtime"` Genres []string `json:"genres,omitempty"` Studio string `json:"studio,omitempty"` Status string `json:"status,omitempty"` TMDBID int `json:"tmdbId"` IMDBID string `json:"imdbId,omitempty"` TitleSlug string `json:"titleSlug,omitempty"` Images []arr.Image `json:"images,omitempty"` Ratings Ratings `json:"ratings"` Monitored bool `json:"monitored"` HasFile bool `json:"hasFile"` QualityProfileID int `json:"qualityProfileId"` InCinemas string `json:"inCinemas,omitempty"` // ISO timestamps, empty if unknown PhysicalRelease string `json:"physicalRelease,omitempty"` DigitalRelease string `json:"digitalRelease,omitempty"` SizeOnDisk int64 `json:"sizeOnDisk"` IsAvailable bool `json:"isAvailable"` Path string `json:"path,omitempty"` MovieFile *arr.MediaFile `json:"movieFile,omitempty"` } // QualityName returns the quality of the downloaded file, or "". func (m Movie) QualityName() string { if m.MovieFile == nil { return "" } return m.MovieFile.Quality.Quality.Name } // InLibrary reports whether the movie has already been added to Radarr. func (m Movie) InLibrary() bool { return m.ID != 0 } // Rating returns the best available rating (IMDb, then TMDb), or 0. func (m Movie) Rating() float64 { switch { case m.Ratings.IMDB != nil && m.Ratings.IMDB.Value > 0: return m.Ratings.IMDB.Value case m.Ratings.TMDB != nil: return m.Ratings.TMDB.Value } return 0 } // Lookup searches for movies by free text ("The Matrix"), "tmdb:603" or "imdb:tt0133093". func (c *Client) Lookup(ctx context.Context, term string) ([]Movie, error) { var movies []Movie err := c.Do(ctx, http.MethodGet, "movie/lookup", url.Values{"term": {term}}, nil, &movies) return movies, err } // Movies lists the movies in the library. func (c *Client) Movies(ctx context.Context) ([]Movie, error) { var movies []Movie err := c.Do(ctx, http.MethodGet, "movie", nil, nil, &movies) return movies, err } // MovieByTMDB returns the library movie with the TMDb id, ok=false if the // library has none. Unlike a lookup it does not ask the metadata provider. func (c *Client) MovieByTMDB(ctx context.Context, tmdbID int) (Movie, bool, error) { var movies []Movie err := c.Do(ctx, http.MethodGet, "movie", url.Values{"tmdbId": {strconv.Itoa(tmdbID)}}, nil, &movies) for _, m := range movies { // checked: a version that ignores the filter sends the whole library if m.TMDBID == tmdbID && m.InLibrary() { return m, true, err } } return Movie{}, false, err } // AddOptions are the choices from the add form. type AddOptions struct { QualityProfileID int RootFolderPath string Monitored bool SearchNow bool MinimumAvailability string // "announced", "inCinemas" or "released"; defaults to "released" } type addMovieRequest struct { Title string `json:"title"` Year int `json:"year"` TMDBID int `json:"tmdbId"` TitleSlug string `json:"titleSlug,omitempty"` Images []arr.Image `json:"images,omitempty"` QualityProfileID int `json:"qualityProfileId"` RootFolderPath string `json:"rootFolderPath"` Monitored bool `json:"monitored"` MinimumAvailability string `json:"minimumAvailability"` AddOptions struct { SearchForMovie bool `json:"searchForMovie"` } `json:"addOptions"` } // AddMovie adds a lookup result to Radarr and returns the created movie. func (c *Client) AddMovie(ctx context.Context, m Movie, opts AddOptions) (Movie, error) { req := addMovieRequest{ Title: m.Title, Year: m.Year, TMDBID: m.TMDBID, TitleSlug: m.TitleSlug, Images: m.Images, QualityProfileID: opts.QualityProfileID, RootFolderPath: opts.RootFolderPath, Monitored: opts.Monitored, MinimumAvailability: opts.MinimumAvailability, } if req.MinimumAvailability == "" { req.MinimumAvailability = "released" } req.AddOptions.SearchForMovie = opts.SearchNow var created Movie err := c.Do(ctx, http.MethodPost, "movie", nil, req, &created) return created, err } // SearchMovie triggers an automatic search for an existing library movie. func (c *Client) SearchMovie(ctx context.Context, movieID int) error { return c.Command(ctx, map[string]any{"name": "MoviesSearch", "movieIds": []int{movieID}}) } // TMDBTerm builds a lookup term for a TMDb id. func TMDBTerm(tmdbID int) string { return "tmdb:" + strconv.Itoa(tmdbID) } // Queue lists Radarr's download queue, including movie info. func (c *Client) Queue(ctx context.Context) ([]arr.QueueItem, error) { return c.QueueWith(ctx, url.Values{"includeMovie": {"true"}}) } // Releases runs an interactive search for a library movie. func (c *Client) Releases(ctx context.Context, movieID int) ([]arr.Release, error) { return c.Client.Releases(ctx, url.Values{"movieId": {strconv.Itoa(movieID)}}) } // SetMonitored changes the monitored flag of a library movie. func (c *Client) SetMonitored(ctx context.Context, movieID int, monitored bool) error { return c.Update(ctx, "movie", movieID, func(o map[string]json.RawMessage) error { arr.SetBool(o, "monitored", monitored) return nil }) } // DeleteMovie removes a movie from the library. With deleteFiles the movie's // files are deleted from disk too; addExclusion keeps it from coming back // through import lists. func (c *Client) DeleteMovie(ctx context.Context, id int, deleteFiles, addExclusion bool) error { q := url.Values{ "deleteFiles": {strconv.FormatBool(deleteFiles)}, "addImportExclusion": {strconv.FormatBool(addExclusion)}, } return c.Do(ctx, http.MethodDelete, "movie/"+strconv.Itoa(id), q, nil, nil) } // Calendar lists the library movies with a release date (cinemas, digital or // physical) between start and end, both inclusive. Unmonitored movies are // only included when asked for. func (c *Client) Calendar(ctx context.Context, start, end time.Time, unmonitored bool) ([]Movie, error) { q := url.Values{ "start": {start.Format("2006-01-02")}, "end": {end.Format("2006-01-02")}, "unmonitored": {strconv.FormatBool(unmonitored)}, } var movies []Movie err := c.Do(ctx, http.MethodGet, "calendar", q, nil, &movies) return movies, err }