Files
arr-tui/internal/arr/image.go
T

186 lines
5.7 KiB
Go

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, &notImageError{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
}