Files
arr-tui/internal/arr/image.go
T
nilsandClaude Opus 5.5 2798977867 Real-image posters of library titles: use the 500 px copy
Library titles fetched Radarr's/Sonarr's 250×375 poster copy, which
real images show at up to 640 pixels, so movie posters looked soft next
to series posters that came from the provider's full-size original
(Sonarr behind a login). With real images the 500×750 copy is used;
block-character posters keep the small one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
2026-10-21 15:31:56 +02:00

197 lines
6.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, preferring a scaled variant: -250 (250×375), or -500
// (500×750) 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, remote); remoteErr == nil {
return remoteData, nil
}
}
return data, err
}, true
}
if img.RemoteURL != "" {
remote := img.RemoteURL
return remote, func(ctx context.Context, _ bool) ([]byte, error) { return FetchURL(ctx, remote) }, true
}
}
return "", nil, false
}
// localPoster fetches a poster from the instance: the scaled variant (-500
// when large, else -250), 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, 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
}
width := 250
if large {
width = 500
}
if scaled := scaledVariant(local, width); 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)
}
// 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, &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
}