Show poster thumbnails in the detail panes

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 21:47:50 +02:00
co-authored by Claude Sonnet 5.5
parent ff204655d3
commit a4303ed3ca
15 changed files with 724 additions and 22 deletions
+120
View File
@@ -0,0 +1,120 @@
package arr
import (
"context"
"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 := img.URL
return c.baseURL + "|" + local, func(ctx context.Context) ([]byte, error) {
// MediaCover has poster-250.jpg next to poster.jpg; fall back to the original.
if small := smallVariant(local); small != local {
if data, err := c.GetBytes(ctx, small); err == nil {
return data, nil
}
}
return c.GetBytes(ctx, local)
}, true
}
if img.RemoteURL != "" {
remote := img.RemoteURL
return remote, func(ctx context.Context) ([]byte, error) { return FetchURL(ctx, remote) }, true
}
}
return "", nil, false
}
// 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) {
u, err := c.resolve(ref)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, err
}
req.Header.Set("X-Api-Key", c.apiKey)
return doImage(c.http, req)
}
// 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) {
base, err := url.Parse(c.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 c.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)
}
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
}
+96
View File
@@ -0,0 +1,96 @@
package arr
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
func TestSmallVariant(t *testing.T) {
for in, want := range map[string]string{
"/MediaCover/1/poster.jpg?lastWrite=5": "/MediaCover/1/poster-250.jpg?lastWrite=5",
"/MediaCover/1/poster.jpg": "/MediaCover/1/poster-250.jpg",
"/MediaCover/1/fanart.jpg": "/MediaCover/1/fanart.jpg",
} {
if got := smallVariant(in); got != want {
t.Errorf("smallVariant(%q) = %q, want %q", in, got, want)
}
}
}
func TestPosterLocalUsesAPIKey(t *testing.T) {
var paths []string
var keys []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.URL.RequestURI())
keys = append(keys, r.Header.Get("X-Api-Key"))
if r.URL.Path == "/MediaCover/1/poster-250.jpg" {
http.NotFound(w, r) // no small variant: fall back to the original
return
}
w.Write([]byte("IMG"))
}))
defer srv.Close()
c := NewClient(srv.URL, "secret")
imgs := []Image{{CoverType: "fanart", URL: "/MediaCover/1/fanart.jpg"}, {CoverType: "poster", URL: "/MediaCover/1/poster.jpg?lastWrite=1", RemoteURL: "https://remote/p.jpg"}}
key, fetch, ok := c.Poster(imgs, true)
if !ok || key == "" {
t.Fatal("no poster")
}
data, err := fetch(context.Background())
if err != nil || string(data) != "IMG" {
t.Fatalf("data=%q err=%v", data, err)
}
if len(paths) != 2 || paths[0] != "/MediaCover/1/poster-250.jpg?lastWrite=1" || paths[1] != "/MediaCover/1/poster.jpg?lastWrite=1" {
t.Errorf("requests = %v", paths)
}
for _, k := range keys {
if k != "secret" {
t.Errorf("api key header = %q", k)
}
}
}
func TestPosterRemoteSendsNoAPIKey(t *testing.T) {
var got string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = r.Header.Get("X-Api-Key")
w.Write([]byte("IMG"))
}))
defer srv.Close()
c := NewClient("http://radarr.invalid", "secret")
key, fetch, ok := c.Poster([]Image{{CoverType: "poster", URL: "/MediaCover/0/poster.jpg", RemoteURL: srv.URL + "/p.jpg"}}, false)
if !ok || key != srv.URL+"/p.jpg" {
t.Fatalf("key=%q ok=%v", key, ok)
}
if _, err := fetch(context.Background()); err != nil {
t.Fatal(err)
}
if got != "" {
t.Errorf("the API key leaked to a remote image host: %q", got)
}
if _, _, ok := c.Poster([]Image{{CoverType: "banner", RemoteURL: "x"}}, false); ok {
t.Error("only posters count")
}
if _, err := FetchURL(context.Background(), "file:///etc/passwd"); err == nil {
t.Error("non-http schemes must be refused")
}
}
func TestResolveURLBase(t *testing.T) {
c := NewClient("http://host:7878/radarr", "k")
for ref, want := range map[string]string{
"/radarr/MediaCover/1/poster.jpg": "http://host:7878/radarr/MediaCover/1/poster.jpg",
"/MediaCover/1/poster.jpg": "http://host:7878/radarr/MediaCover/1/poster.jpg",
} {
if got, _ := c.resolve(ref); got != want {
t.Errorf("resolve(%q) = %q, want %q", ref, got, want)
}
}
if got, _ := NewClient("http://host", "k").resolve("/MediaCover/1/poster.jpg"); got != "http://host/MediaCover/1/poster.jpg" {
t.Errorf("no url base: %q", got)
}
}