package arr import ( "context" "errors" "fmt" "io" "net/http" "net/url" "strings" "time" ) const maxImageBytes = 8 << 20 // Poster returns a key identifying the poster among cached images and a // function that downloads it, or ok=false when there is none. // // Library items have a copy on the Radarr/Sonarr server, which is fetched // with the API key (preferring the small -250 variant). Titles that are not // in the library yet only have the remote URL of the metadata provider; that // is fetched without the API key. func (c *Client) Poster(images []Image, inLibrary bool) (key string, fetch func(ctx context.Context) ([]byte, error), ok bool) { for _, img := range images { if img.CoverType != "poster" { continue } if inLibrary && img.URL != "" { local, remote := img.URL, img.RemoteURL base, _ := c.settings() return base + "|" + local, func(ctx context.Context) ([]byte, error) { data, err := c.localPoster(ctx, local) if err != nil && remote != "" { // The instance has no usable copy (not cached, or behind a login): // use the metadata provider's, as for titles not in the library. if remoteData, remoteErr := FetchURL(ctx, remote); remoteErr == nil { return remoteData, nil } } return data, err }, true } if img.RemoteURL != "" { remote := img.RemoteURL return remote, func(ctx context.Context) ([]byte, error) { return FetchURL(ctx, remote) }, true } } return "", nil, false } // localPoster fetches a poster from the instance: the small variant, then the // original. When the instance answers with a page instead of an image (it // wants a login for /MediaCover) the API key is also tried as a query // parameter, which is the other way Radarr and Sonarr accept it. func (c *Client) localPoster(ctx context.Context, local string) ([]byte, error) { try := func(ref string) ([]byte, error) { data, err := c.GetBytes(ctx, ref) var page *notImageError if errors.As(err, &page) { if data, err2 := c.getBytes(ctx, ref, true); err2 == nil { return data, nil } } return data, err } if small := smallVariant(local); small != local { data, err := try(small) if err == nil { return data, nil } var page *notImageError if errors.As(err, &page) { return nil, err // the original would be a page as well } } return try(local) } // smallVariant turns /MediaCover/1/poster.jpg?x into /MediaCover/1/poster-250.jpg?x. func smallVariant(p string) string { path, query, hasQuery := strings.Cut(p, "?") if !strings.HasSuffix(path, "/poster.jpg") { return p } path = strings.TrimSuffix(path, ".jpg") + "-250.jpg" if hasQuery { return path + "?" + query } return path } // GetBytes downloads a server-relative path such as /MediaCover/1/poster.jpg // from the instance, authenticated with the API key. func (c *Client) GetBytes(ctx context.Context, ref string) ([]byte, error) { return c.getBytes(ctx, ref, false) } // getBytes sends the API key as a header, or with keyInQuery as the apikey // query parameter. func (c *Client) getBytes(ctx context.Context, ref string, keyInQuery bool) ([]byte, error) { u, err := c.resolve(ref) if err != nil { return nil, err } _, apiKey := c.settings() if keyInQuery { parsed, err := url.Parse(u) if err != nil { return nil, err } q := parsed.Query() q.Set("apikey", apiKey) parsed.RawQuery = q.Encode() u = parsed.String() } req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { return nil, err } if !keyInQuery { req.Header.Set("X-Api-Key", apiKey) } return doImage(c.http, req) } // notImageError means the server answered 200 with something that is not an // image, typically the login page of an instance that requires authentication. type notImageError struct{ contentType string } func (e *notImageError) Error() string { return fmt.Sprintf("image: the server sent %q instead of an image (a login page?)", e.contentType) } // resolve joins a server-relative path to the base URL. Radarr/Sonarr include // their URL base in image paths, so it must not be added twice. func (c *Client) resolve(ref string) (string, error) { baseURL, _ := c.settings() base, err := url.Parse(baseURL) if err != nil { return "", err } if base.Path != "" && base.Path != "/" && strings.HasPrefix(ref, strings.TrimRight(base.Path, "/")+"/") { return base.Scheme + "://" + base.Host + ref, nil } return baseURL + ref, nil } var imageClient = &http.Client{Timeout: 20 * time.Second} // FetchURL downloads a public image. No credentials are sent. func FetchURL(ctx context.Context, rawURL string) ([]byte, error) { u, err := url.Parse(rawURL) if err != nil || (u.Scheme != "http" && u.Scheme != "https") { return nil, fmt.Errorf("unsupported image URL") } req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) if err != nil { return nil, err } return doImage(imageClient, req) } func doImage(client *http.Client, req *http.Request) ([]byte, error) { resp, err := client.Do(req) if err != nil { return nil, stripURL(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("image: HTTP %d", resp.StatusCode) } // Pages and error documents. (Not all of text/*: a plain server may label // image bytes it cannot identify as text/plain.) if ct := resp.Header.Get("Content-Type"); strings.HasPrefix(ct, "text/html") || strings.HasPrefix(ct, "application/xhtml") || strings.HasPrefix(ct, "application/json") { return nil, ¬ImageError{contentType: ct} } data, err := io.ReadAll(io.LimitReader(resp.Body, maxImageBytes+1)) if err != nil { return nil, err } if len(data) > maxImageBytes { return nil, fmt.Errorf("image larger than %d MB", maxImageBytes>>20) } return data, nil }