// 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" "github.com/ponzischeme89/memby/server/internal/timing" ) 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: timing.Instrument(&http.Client{ Timeout: timeout, Transport: &http.Transport{ MaxIdleConns: 20, MaxIdleConnsPerHost: 10, IdleConnTimeout: 90 * time.Second, }, }, timing.StageRadarr), } } // 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 }