Publish current app and server
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
// 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"`
|
||||
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
|
||||
}
|
||||
|
||||
// AddUnmonitored adds a title without starting a search or monitoring future releases.
|
||||
func (c *Client) AddUnmonitored(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 = false
|
||||
body := struct {
|
||||
Movie
|
||||
AddOptions map[string]bool `json:"addOptions"`
|
||||
}{Movie: movie, AddOptions: map[string]bool{"searchForMovie": false}}
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user