219 lines
6.9 KiB
Go
219 lines
6.9 KiB
Go
package arr
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"strconv"
|
||
"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: the copy 250 pixels tall (-250), or the original when
|
||
// fetch is asked for a large poster. 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, large bool) ([]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, large bool) ([]byte, error) {
|
||
data, err := c.localPoster(ctx, local, large)
|
||
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, tmdbSized(remote, large)); remoteErr == nil {
|
||
return remoteData, nil
|
||
}
|
||
}
|
||
return data, err
|
||
}, true
|
||
}
|
||
if img.RemoteURL != "" {
|
||
remote := img.RemoteURL
|
||
return remote, func(ctx context.Context, large bool) ([]byte, error) {
|
||
return FetchURL(ctx, tmdbSized(remote, large))
|
||
}, true
|
||
}
|
||
}
|
||
return "", nil, false
|
||
}
|
||
|
||
// localPoster fetches a poster from the instance: the small variant (-250),
|
||
// then the original; when large, only 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, large bool) ([]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
|
||
}
|
||
// Radarr/Sonarr keep copies 250 and 500 pixels tall; even the larger one
|
||
// is smaller than a real image is shown, so those take the original.
|
||
if large {
|
||
return try(local)
|
||
}
|
||
if scaled := scaledVariant(local, 250); scaled != local {
|
||
data, err := try(scaled)
|
||
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)
|
||
}
|
||
|
||
// tmdbSized asks TheMovieDB for a poster of the size that is shown instead of
|
||
// the original Radarr hands out (often 2000×3000 and over 2 MB, which takes
|
||
// longer to download and to decode than everything else together): 780
|
||
// pixels wide for real images, 342 for block characters. Other URLs are
|
||
// returned as they are.
|
||
func tmdbSized(rawURL string, large bool) string {
|
||
u, err := url.Parse(rawURL)
|
||
if err != nil || u.Host != "image.tmdb.org" || !strings.HasPrefix(u.Path, "/t/p/original/") {
|
||
return rawURL
|
||
}
|
||
size := "w342"
|
||
if large {
|
||
size = "w780"
|
||
}
|
||
u.Path = "/t/p/" + size + strings.TrimPrefix(u.Path, "/t/p/original")
|
||
return u.String()
|
||
}
|
||
|
||
// scaledVariant turns /MediaCover/1/poster.jpg?x into
|
||
// /MediaCover/1/poster-250.jpg?x (for width 250; Radarr and Sonarr keep 250
|
||
// and 500).
|
||
func scaledVariant(p string, width int) string {
|
||
path, query, hasQuery := strings.Cut(p, "?")
|
||
if !strings.HasSuffix(path, "/poster.jpg") {
|
||
return p
|
||
}
|
||
path = strings.TrimSuffix(path, ".jpg") + "-" + strconv.Itoa(width) + ".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()
|
||
if baseURL == "" {
|
||
return "", ErrNotConfigured
|
||
}
|
||
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
|
||
}
|