Files
arr-tui/internal/radarr/radarr.go
T
nilsandClaude Opus 5.5 f0bc284e82 Trakt lists: keep the Radarr/Sonarr lookups of their titles on disk
Library membership is asked every time via movie?tmdbId= / series?tvdbId=;
only the lookups of titles not in the library are stored (3 days).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 20:48:20 +02:00

215 lines
7.7 KiB
Go

// 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
}